diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78d69c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test ./... + - name: Cross-compile + run: | + GOOS=windows GOARCH=amd64 go build -o /dev/null . + GOOS=linux GOARCH=arm64 go build -o /dev/null . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ccabe66..7d87cba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,6 @@ -name: Publish to PyPI +# Release: GitHub release (binaries + checksums), Homebrew cask, and PyPI wheels. +# Keep this filename: PyPI trusted publishing is bound to it. +name: Release on: push: @@ -16,12 +18,12 @@ on: - major jobs: - publish: + release: runs-on: ubuntu-latest environment: pypi permissions: - id-token: write - contents: write + id-token: write # PyPI trusted publishing + contents: write # GitHub release steps: - uses: actions/checkout@v4 with: @@ -48,19 +50,31 @@ jobs: git tag "v${{ steps.version.outputs.next }}" git push origin "v${{ steps.version.outputs.next }}" + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test + run: go test ./... + + - name: GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Fine-grained PAT with contents:write on mihirgupta0900/homebrew-tap. + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Build - run: pip install hatch && hatch build + - name: Build PyPI wheels + run: python scripts/build_wheels.py - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', steps.version.outputs.next) || github.ref_name }} - generate_release_notes: true - files: dist/* + packages-dir: dist/wheels diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9ca181c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,88 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 +project_name: spawnpoint + +before: + hooks: + - go mod tidy + +builds: + # GitHub releases, Homebrew, install.sh and `sp update` self-updates. + - id: spawnpoint + main: . + binary: spawnpoint + env: [CGO_ENABLED=0] + goos: [darwin, linux, windows] + goarch: [amd64, arm64] + ignore: + - goos: windows + goarch: arm64 + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} + + # Same binary, marked as installed from PyPI so `sp update` defers to + # pipx / uv / pip. Packed into wheels by scripts/build_wheels.py. + - id: pypi + main: . + binary: spawnpoint + env: [CGO_ENABLED=0] + goos: [darwin, linux, windows] + goarch: [amd64, arm64] + ignore: + - goos: windows + goarch: arm64 + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} -X github.com/mihirgupta0900/spawnpoint/internal/update.Channel=pypi + +archives: + - id: spawnpoint + ids: [spawnpoint] + # No version in the name, so /releases/latest/download/ always works. + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: [LICENSE, README.md] + +checksum: + name_template: checksums.txt + +homebrew_casks: + - name: spawnpoint + ids: [spawnpoint] + repository: + owner: mihirgupta0900 + name: homebrew-tap + # Push with a deploy key scoped to the tap repo (secret HOMEBREW_TAP_DEPLOY_KEY) + # rather than a personal access token. + git: + url: git@github.com:mihirgupta0900/homebrew-tap.git + private_key: "{{ .Env.HOMEBREW_TAP_DEPLOY_KEY }}" + homepage: https://github.com/mihirgupta0900/spawnpoint + description: "One branch. Every repo. Ready to code. Multi-repo git worktree workspaces for you and your coding agents." + license: MIT + binaries: [spawnpoint] + custom_block: | + binary "spawnpoint", target: "sp" + hooks: + post: + # The binary isn't notarized; clear the quarantine flag so macOS runs it. + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/spawnpoint"] + end + +# Local test builds: GORELEASER_CURRENT_TAG=v1.2.3 goreleaser release --snapshot --clean +snapshot: + version_template: "{{ .Version }}" + +changelog: + use: github-native + +release: + github: + owner: mihirgupta0900 + name: spawnpoint diff --git a/README.md b/README.md index 22703a8..e4d7dea 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,140 @@ +
+ # Spawnpoint -![Spawnpoint](./assets/social-preview.png) +**One branch. Every repo. Ready to code.** + +Spawnpoint gives every task its own folder: a git worktree for each repo it touches, all on the same branch, with `.env` files copied and dependencies installed.
+Point Claude Code, Codex, Cursor, Gemini CLI, or yourself at it and start working. + +[![Watch the 30-second overview](./assets/spawnpoint.gif)](./assets/spawnpoint.mp4) + +▶ Watch in HD with sound (MP4) + +
+ +```sh +brew install mihirgupta0900/tap/spawnpoint # or: curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh +sp create # pick repos, name a branch, done +``` + +One native binary, about 20 ms to start, and `sp update` keeps it current however you installed it ([more options](#install)). + +## Why Spawnpoint + +Worktrees are how you run agents in parallel without them tripping over each other. They don't make a fresh checkout usable, though, and they only cover one repo at a time. + +- **Your feature spans repos.** Billing touches `api`, `web`, and `worker`. Most worktree tools handle one repo at a time. Spawnpoint puts all three in one folder on one branch, so you and your agent see the whole change. +- **A fresh worktree won't run yet.** It has no `.env`, no `node_modules`, no `.venv`, and no submodules, so your agent's first ten minutes go to setup. Spawnpoint copies env and agent files (`.env*`, `CLAUDE.md`, `AGENT.md`) and installs dependencies with whatever each repo uses: npm, pnpm, yarn, bun, uv, poetry, pip, bundler, or go. +- **Parallel agents need separate checkouts.** Two agents in one checkout overwrite each other's edits and branch switches. With a workspace per task, each agent gets its own branch, files, and dependencies. +- **Old worktrees fill your disk.** Ten worktrees with `node_modules` add up to gigabytes. `sp light-cleanup` deletes the reinstallable directories and keeps your code. + +``` +~/.spawnpoint/workspaces/feat-billing/ +├── api/ ⎇ feat/billing .env CLAUDE.md .venv +├── web/ ⎇ feat/billing .env.local CLAUDE.md node_modules +└── worker/ ⎇ feat/billing .env submodules node_modules +``` + +The checkouts in `~/code` are never touched. + +## Built for coding agents + +Spawnpoint builds the workspace and leaves the rest to you. It isn't an agent GUI, so any agent, editor, or terminal can use it, and an agent can create its own workspaces. + +**Install the agent skill** ([skills.sh](https://www.skills.sh)). Claude Code, Codex, and other skill-aware agents learn to call Spawnpoint headlessly: + +```sh +npx skills add mihirgupta0900/spawnpoint +``` -Spawn multi-repo worktree workspaces for feature development. +**Or drive it from any script or agent.** Every command runs with `--no-input --json`, never waits on a prompt, and prints JSON you can parse: -Working on a feature that spans multiple repos? Spawnpoint creates a dedicated folder with git worktrees from each repo on the same branch, installs dependencies, and copies over config files — so you can start coding (or start a Claude session) immediately. +```sh +$ spawnpoint create --no-input --json --repos api,web --branch feat/billing +{ + "workspace": "/Users/you/.spawnpoint/workspaces/feat-billing", + "branch": "feat/billing", + "template": null, + "repos": [ + { "name": "api", "branch": "feat/billing", "base": "main", "action": "create", "status": "created" }, + { "name": "web", "branch": "feat/billing", "base": "main", "action": "create", "status": "created" } + ] +} +``` -![Demo](./demo/demo.gif) +A typical parallel setup runs one workspace per agent: + +```sh +sp create --no-input --repos api,web --branch feat/billing # → Claude Code +sp create --no-input --repos api,worker --branch fix/auth-timeout # → Codex +sp create --no-input --template search-stack --branch feat/search # → Gemini CLI +``` + +See [Non-Interactive Mode](#non-interactive-mode-for-agents--scripts) for every flag. ## Install ### macOS app -Download the latest `.dmg` from [Releases](https://github.com/mihirgupta0900/spawnpoint/releases?q=mac) and drag Spawnpoint into Applications. Lives in your menu bar — no terminal required. +Download the latest `.dmg` from [Releases](https://github.com/mihirgupta0900/spawnpoint/releases?q=mac) and drag Spawnpoint into Applications. It lives in your menu bar, so you don't need a terminal. Source: [`mac/`](./mac). ### CLI -``` +Spawnpoint is a single native binary for macOS, Linux and Windows. Nothing else is needed at runtime besides `git`. Pick whichever installer you already use: + +```sh +# Homebrew (macOS, Linux) +brew install mihirgupta0900/tap/spawnpoint + +# Install script: downloads the release binary and verifies its checksum +curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh + +# pipx / uv / pip: same native binary, packaged as a wheel pipx install spawnpoint +uv tool install spawnpoint + +# Go +go install github.com/mihirgupta0900/spawnpoint@latest ``` -Or with pip: +Or download a build from [Releases](https://github.com/mihirgupta0900/spawnpoint/releases). The install script puts the binary in `~/.local/bin`. Set `SPAWNPOINT_INSTALL_DIR` to change that, or `SPAWNPOINT_VERSION` to pin a version. +This installs both `spawnpoint` and `sp` as commands (`go install` only provides `spawnpoint`; the [shell integration](#shell-integration) adds `sp`). All examples below use `sp` for brevity. + +## Updating + +```sh +sp update # upgrade to the latest release +sp update --check # just report whether one is available ``` -pip install spawnpoint -``` -This installs both `spawnpoint` and `sp` as CLI commands. All examples below use `sp` for brevity. +`sp update` detects how Spawnpoint was installed and upgrades it the same way, so it never ends up with two copies: + +| Installed with | `sp update` runs | +|---|---| +| Homebrew | `brew upgrade spawnpoint` | +| pipx | `pipx upgrade spawnpoint` | +| uv | `uv tool upgrade spawnpoint` | +| pip | `python -m pip install --upgrade spawnpoint` | +| `go install` | `go install github.com/mihirgupta0900/spawnpoint@latest` | +| Install script / manual download | downloads the new release, verifies its checksum, and swaps the binary in place | + +Spawnpoint checks for new releases in the background at most once a day, then prints a one-line notice after a command. The check is skipped in `--json` / `--no-input` mode, and you can turn it off with `check_updates = false`. + +### Coming from the Python version (0.11 and earlier) + +You don't have to do anything special. Run `sp update` (or `pipx upgrade spawnpoint`) as usual, and pipx swaps the Python package for the native binary. Everything else stays as it is: + +- `~/.spawnpoint/config.toml` and `templates.toml` are read as-is +- existing workspaces, the `sp()` shell function and the agent skill keep working +- every `--no-input` / `--json` flag and output shape is unchanged + +What you get: startup in about 20 ms instead of about 90 ms, `list` roughly twice as fast, repos fetched in parallel, and no Python install to maintain. `--json` output is now always clean JSON. The Python release could mix progress output into stdout. + +If you'd rather switch to Homebrew or the install script, remove the pipx copy first: `pipx uninstall spawnpoint`. The install script refuses to overwrite a pipx or uv install, so you won't end up with two copies. ## Quick Start @@ -44,19 +150,19 @@ sp template save image-gen # remember a set of repos you spawn together sp create -t image-gen # spawn that set again ``` -On first run, Spawnpoint will ask you to configure your scan directories and workspace location. +On first run, Spawnpoint asks you to configure your scan directories and workspace location. ## How It Works -1. **Select repos** — Spawnpoint scans your code directories and presents a fuzzy-searchable list of git repos -2. **Name a branch** — Enter a branch name for your feature -3. **Spawn** — For each repo, Spawnpoint: - - Creates a git worktree (or new branch if needed) +1. **Select repos.** Spawnpoint scans your code directories and shows a fuzzy-searchable list of git repos. +2. **Name a branch.** Enter a branch name for your feature. +3. **Spawn.** For each repo, Spawnpoint: + - Creates a git worktree, and a new branch if needed, from the latest remote tip - Initializes submodules - Copies `.env` files, `CLAUDE.md`, and other config files from the original repo - Installs dependencies (detects npm/pnpm/yarn/bun, pip/uv/poetry, bundler, go modules) -All worktrees land in a single folder (`~/.spawnpoint/workspaces//`) so you can open the whole workspace in your editor or start an AI coding session. +All worktrees land in a single folder (`~/.spawnpoint/workspaces//`). Open the whole workspace in your editor or start an AI coding session there. ## Commands @@ -79,8 +185,10 @@ All worktrees land in a single folder (`~/.spawnpoint/workspaces//` | `sp config` | View current config | | `sp config --edit` | Edit config in $EDITOR | | `sp config --reset` | Reset to defaults | -| `sp update` | Update to latest version | +| `sp update` | Update to the latest version (via brew/pipx/uv/pip when that's how it was installed) | +| `sp update --check` | Check for an update without installing it | | `sp --version` | Show version | +| `sp completion ` | Print a shell completion script (bash, zsh, fish, powershell) | ### Adding repos to a workspace @@ -310,6 +418,8 @@ additional_worktree_dirs = ['~/.spawnpoint/workspaces'] When creating a new branch, Spawnpoint automatically detects the repo's default branch to use as the base. No configuration needed. +Set `SPAWNPOINT_DIR` to use a config directory other than `~/.spawnpoint`, e.g. for a throwaway setup in tests. The shell integration's auto-cd always reads `~/.spawnpoint/.cd_path`. + ## Shell Integration During `sp init`, you'll be offered to install a shell function that wraps common commands with auto-cd: @@ -342,13 +452,25 @@ Without shell integration, `sp` still works for all commands — you just won't ## Requirements -- Python 3.10+ - git +- macOS 12+, Linux (x86_64 / arm64, glibc or musl), or Windows (x86_64) + +## Development + +```sh +go test ./... # unit + end-to-end tests (real git repos in a temp $HOME) +go run . --help +GORELEASER_CURRENT_TAG=v1.2.3 goreleaser release --snapshot --clean # local release build +python scripts/build_wheels.py --version 1.2.3 # PyPI wheels from that build +``` + +The e2e suite in `e2e/` pins the `--no-input` / `--json` contract. Point it at another build with `SPAWNPOINT_BIN=/path/to/spawnpoint go test ./e2e`. ## Uninstall +Remove it with the tool you installed it with (`brew uninstall spawnpoint`, `pipx uninstall spawnpoint`, `uv tool uninstall spawnpoint`), or delete `~/.local/bin/spawnpoint` and `~/.local/bin/sp` for the install script. Then remove your config and templates: + ``` -pipx uninstall spawnpoint rm -rf ~/.spawnpoint ``` diff --git a/assets/social-preview.png b/assets/social-preview.png index d7b633c..8c09da4 100644 Binary files a/assets/social-preview.png and b/assets/social-preview.png differ diff --git a/assets/spawnpoint.gif b/assets/spawnpoint.gif new file mode 100644 index 0000000..ed6a4bd Binary files /dev/null and b/assets/spawnpoint.gif differ diff --git a/assets/spawnpoint.mp4 b/assets/spawnpoint.mp4 new file mode 100644 index 0000000..a969308 Binary files /dev/null and b/assets/spawnpoint.mp4 differ diff --git a/e2e/demo_test.go b/e2e/demo_test.go new file mode 100644 index 0000000..7a8cb68 --- /dev/null +++ b/e2e/demo_test.go @@ -0,0 +1,47 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" +) + +// TestDemoFixture builds a persistent demo environment for screenshots and +// recordings when SP_DEMO_DIR is set: +// +// SP_DEMO_DIR=/tmp/sp-demo go test ./e2e -run TestDemoFixture +// SPAWNPOINT_DIR=/tmp/sp-demo/config spawnpoint create +func TestDemoFixture(t *testing.T) { + dir := os.Getenv("SP_DEMO_DIR") + if dir == "" { + t.Skip("set SP_DEMO_DIR to build the demo fixture") + } + os.RemoveAll(dir) + code := filepath.Join(dir, "code") + os.MkdirAll(code, 0o755) + for _, name := range []string{"api", "web", "worker", "billing-docs", "infra", "mobile", "design-system"} { + remote := filepath.Join(dir, "remotes", name+".git") + os.MkdirAll(remote, 0o755) + git(t, remote, "init", "-q", "--bare", "-b", "main") + git(t, code, "clone", "-q", remote, name) + work := filepath.Join(code, name) + os.WriteFile(filepath.Join(work, "README.md"), []byte("# "+name+"\n"), 0o644) + os.WriteFile(filepath.Join(work, ".gitignore"), []byte(".env*\nCLAUDE.md\nnode_modules/\n"), 0o644) + if name == "web" { + os.WriteFile(filepath.Join(work, "package.json"), []byte(`{"name":"web","version":"1.0.0"}`), 0o644) + } + git(t, work, "add", ".") + git(t, work, "commit", "-qm", "init") + git(t, work, "push", "-q", "origin", "main") + git(t, work, "remote", "set-head", "origin", "--auto") + os.WriteFile(filepath.Join(work, ".env"), []byte("SECRET="+name+"\n"), 0o644) + os.WriteFile(filepath.Join(work, "CLAUDE.md"), []byte("notes\n"), 0o644) + } + cfgDir := filepath.Join(dir, "config") + os.MkdirAll(cfgDir, 0o755) + cfg := "scan_dirs = ['" + code + "']\n" + + "worktree_dir = '" + filepath.Join(dir, "workspaces") + "'\n" + + "auto_install_deps = true\n" + + "check_updates = false\n" + os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(cfg), 0o644) +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..6ae44b3 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,550 @@ +// Package e2e drives the spawnpoint binary against real git repos in a +// throwaway $HOME. It pins the --no-input / --json contract that agents and +// scripts depend on. +// +// By default it builds the binary from this module. Set SPAWNPOINT_BIN to test +// another build (e.g. the legacy Python CLI, with SPAWNPOINT_IMPL=python to +// skip the cases where the Go port intentionally differs). +package e2e + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var bin string + +func TestMain(m *testing.M) { + if b := os.Getenv("SPAWNPOINT_BIN"); b != "" { + bin = b + os.Exit(m.Run()) + } + dir, err := os.MkdirTemp("", "sp-e2e-bin") + if err != nil { + panic(err) + } + bin = filepath.Join(dir, "spawnpoint") + build := exec.Command("go", "build", "-o", bin, "..") + build.Stdout, build.Stderr = os.Stderr, os.Stderr + if err := build.Run(); err != nil { + panic(err) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +func goOnly(t *testing.T) { + if os.Getenv("SPAWNPOINT_IMPL") == "python" { + t.Skip("behaviour intentionally differs from the Python release") + } +} + +type env struct { + t *testing.T + home string + code string + wsRoot string +} + +func realpath(t *testing.T, p string) string { + r, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatal(err) + } + return r +} + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = gitEnv(os.Environ()) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +func gitEnv(base []string) []string { + return append(base, + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=t@example.com", + "GIT_CONFIG_NOSYSTEM=1", + ) +} + +// newEnv makes $HOME with ~/code/{api,web,worker}, each cloned from a bare +// "origin" with a main branch, plus a config pointing at them. +func newEnv(t *testing.T, repos ...string) *env { + t.Helper() + root := realpath(t, t.TempDir()) + e := &env{t: t, home: filepath.Join(root, "home")} + e.code = filepath.Join(e.home, "code") + e.wsRoot = filepath.Join(e.home, ".spawnpoint", "workspaces") + if err := os.MkdirAll(e.code, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(e.home, ".gitconfig"), []byte("[init]\n\tdefaultBranch = main\n"), 0o644) + + if len(repos) == 0 { + repos = []string{"api", "web", "worker"} + } + for _, name := range repos { + remote := filepath.Join(root, "remotes", name+".git") + os.MkdirAll(remote, 0o755) + e.git(remote, "init", "--bare", "-b", "main") + work := filepath.Join(e.code, name) + e.git(e.code, "clone", "-q", remote, name) + os.WriteFile(filepath.Join(work, "README.md"), []byte("# "+name+"\n"), 0o644) + os.WriteFile(filepath.Join(work, ".gitignore"), []byte(".env*\nCLAUDE.md\nnode_modules/\n"), 0o644) + e.git(work, "add", ".") + e.git(work, "commit", "-qm", "init") + e.git(work, "push", "-q", "origin", "main") + e.git(work, "remote", "set-head", "origin", "--auto") + os.WriteFile(filepath.Join(work, ".env"), []byte("SECRET="+name+"\n"), 0o644) + os.WriteFile(filepath.Join(work, "CLAUDE.md"), []byte("agent notes\n"), 0o644) + } + + os.MkdirAll(filepath.Join(e.home, ".spawnpoint"), 0o755) + cfg := "scan_dirs = ['" + e.code + "']\n" + + "worktree_dir = '" + e.wsRoot + "'\n" + + "auto_install_deps = false\n" + + "check_updates = false\n" + if err := os.WriteFile(filepath.Join(e.home, ".spawnpoint", "config.toml"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + return e +} + +func (e *env) env() []string { + var base []string + for _, kv := range os.Environ() { + if !strings.HasPrefix(kv, "HOME=") && !strings.HasPrefix(kv, "XDG_CONFIG_HOME=") { + base = append(base, kv) + } + } + return gitEnv(append(base, "HOME="+e.home, "NO_COLOR=1")) +} + +func (e *env) git(dir string, args ...string) string { + e.t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = e.env() + out, err := cmd.CombinedOutput() + if err != nil { + e.t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +type result struct { + stdout, stderr string + code int +} + +func (e *env) run(dir string, args ...string) result { + e.t.Helper() + if dir == "" { + dir = e.home + } + parts := strings.Fields(bin) // allow "uv run spawnpoint"-style wrappers + cmd := exec.Command(parts[0], append(parts[1:], args...)...) + cmd.Dir = dir + cmd.Env = e.env() + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := result{out.String(), errb.String(), 0} + if ee, ok := err.(*exec.ExitError); ok { + r.code = ee.ExitCode() + } else if err != nil { + e.t.Fatal(err) + } + return r +} + +// ok runs a command that must succeed. +func (e *env) ok(dir string, args ...string) result { + e.t.Helper() + r := e.run(dir, args...) + if r.code != 0 { + e.t.Fatalf("spawnpoint %v exited %d\nstdout: %s\nstderr: %s", args, r.code, r.stdout, r.stderr) + } + return r +} + +// json runs a command that must succeed and decodes its stdout. +func (e *env) json(dir string, v any, args ...string) { + e.t.Helper() + r := e.ok(dir, args...) + if err := json.Unmarshal([]byte(r.stdout), v); err != nil { + e.t.Fatalf("stdout of %v is not JSON: %v\n%s\nstderr: %s", args, err, r.stdout, r.stderr) + } +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +type createOut struct { + Workspace string `json:"workspace"` + Branch string `json:"branch"` + Template *string `json:"template"` + Repos []struct { + Name string `json:"name"` + Branch string `json:"branch"` + Base *string `json:"base"` + Action string `json:"action"` + Status string `json:"status"` + } `json:"repos"` +} + +func TestRepos(t *testing.T) { + e := newEnv(t) + var repos []struct{ Name, Path string } + e.json("", &repos, "repos", "--json") + if len(repos) != 3 || repos[0].Name != "api" || repos[2].Name != "worker" { + t.Fatalf("repos = %+v", repos) + } + if repos[0].Path != filepath.Join(e.code, "api") { + t.Fatalf("path = %s", repos[0].Path) + } +} + +func TestCreateMultiRepo(t *testing.T) { + e := newEnv(t) + var out createOut + e.json("", &out, "create", "--no-input", "--json", "--repos", "api,web", "--branch", "feat/x") + + ws := filepath.Join(e.wsRoot, "feat-x") + if out.Workspace != ws || out.Branch != "feat/x" || out.Template != nil { + t.Fatalf("out = %+v", out) + } + if len(out.Repos) != 2 { + t.Fatalf("repos = %+v", out.Repos) + } + for _, r := range out.Repos { + if r.Action != "create" || r.Status != "created" || r.Base == nil || *r.Base != "main" { + t.Fatalf("repo = %+v", r) + } + } + for _, f := range []string{"api/.env", "api/CLAUDE.md", "web/.env", "api/README.md"} { + if !exists(filepath.Join(ws, f)) { + t.Errorf("missing %s", f) + } + } + if b := e.git(filepath.Join(ws, "api"), "rev-parse", "--abbrev-ref", "HEAD"); b != "feat/x" { + t.Fatalf("branch = %s", b) + } + // New branches must not track the base branch (git push would target main). + if up := e.run(filepath.Join(ws, "api"), "--version"); up.code != 0 { + t.Fatal(up.stderr) + } + cmd := exec.Command("git", "config", "branch.feat/x.merge") + cmd.Dir = filepath.Join(e.code, "api") + cmd.Env = e.env() + if out, _ := cmd.Output(); len(bytes.TrimSpace(out)) != 0 { + t.Fatalf("feat/x tracks %s", out) + } + cdFile, _ := os.ReadFile(filepath.Join(e.home, ".spawnpoint", ".cd_path")) + if string(cdFile) != ws { + t.Fatalf(".cd_path = %q", cdFile) + } +} + +func TestCreatePrintsPathWithoutJSON(t *testing.T) { + e := newEnv(t) + r := e.ok("", "create", "--no-input", "--repos", "api", "--branch", "solo") + if got := strings.TrimSpace(r.stdout); got != filepath.Join(e.wsRoot, "solo") { + t.Fatalf("stdout = %q", r.stdout) + } + // Single repo: the workspace folder is the worktree itself. + if !exists(filepath.Join(e.wsRoot, "solo", ".git")) { + t.Fatal("single-repo worktree missing") + } +} + +func TestCreateExistingBranch(t *testing.T) { + e := newEnv(t) + e.git(filepath.Join(e.code, "api"), "branch", "feat/old") + var out createOut + e.json("", &out, "create", "--no-input", "--json", "--repos", "api,web", "--branch", "feat/old") + if out.Repos[0].Action != "add" || out.Repos[0].Base != nil || out.Repos[1].Action != "create" { + t.Fatalf("repos = %+v", out.Repos) + } +} + +func TestCreateErrors(t *testing.T) { + e := newEnv(t) + cases := []struct { + args []string + want string + }{ + {[]string{"create", "--no-input", "--repos", "nope", "--branch", "x"}, "Valid: api, web, worker"}, + {[]string{"create", "--no-input", "--repos", "api"}, "--no-input requires --branch"}, + {[]string{"create", "--no-input", "--branch", "x"}, "--no-input requires --repos or --template"}, + {[]string{"create", "--no-input", "--template", "missing", "--branch", "x"}, "template 'missing' not found"}, + } + for _, c := range cases { + r := e.run("", c.args...) + if r.code == 0 || !strings.Contains(r.stderr, c.want) { + t.Errorf("%v: code=%d stderr=%q, want %q", c.args, r.code, r.stderr, c.want) + } + if r.stdout != "" { + t.Errorf("%v: stdout should be empty, got %q", c.args, r.stdout) + } + } +} + +func TestListAndCD(t *testing.T) { + e := newEnv(t) + var empty []any + e.json("", &empty, "list", "--json") + if len(empty) != 0 { + t.Fatalf("list = %v", empty) + } + + e.ok("", "create", "--no-input", "--repos", "api,web", "--branch", "feat/x") + os.WriteFile(filepath.Join(e.wsRoot, "feat-x", "web", "new.txt"), []byte("x"), 0o644) + + var list []struct { + Name string + Path string + Repos int + Branches []string + Dirty bool + } + e.json("", &list, "list", "--json") + if len(list) != 1 || list[0].Name != "feat-x" || list[0].Repos != 2 || !list[0].Dirty || + len(list[0].Branches) != 1 || list[0].Branches[0] != "feat/x" { + t.Fatalf("list = %+v", list) + } + + r := e.ok("", "list", "--cd", "--no-input", "--workspace", "feat-x") + if strings.TrimSpace(r.stdout) != filepath.Join(e.wsRoot, "feat-x") { + t.Fatalf("cd stdout = %q", r.stdout) + } + if r := e.run("", "list", "--cd", "--no-input", "--workspace", "nope"); r.code == 0 || !strings.Contains(r.stderr, "Valid: feat-x") { + t.Fatalf("bad workspace: %+v", r) + } +} + +type addOut struct { + Workspace string `json:"workspace"` + Branch string `json:"branch"` + Added []struct { + Name, Action, Status string + } `json:"added"` +} + +func TestAdd(t *testing.T) { + e := newEnv(t) + e.ok("", "create", "--no-input", "--repos", "api,web", "--branch", "feat/x") + ws := filepath.Join(e.wsRoot, "feat-x") + + // Repos already in the workspace aren't offered again. + if r := e.run(ws, "add", "--no-input", "--repos", "api"); r.code == 0 || !strings.Contains(r.stderr, "not found") { + t.Fatalf("re-adding api: %+v", r) + } + + var out addOut + e.json(filepath.Join(ws, "api"), &out, "add", "--no-input", "--json", "--repos", "worker") + if out.Workspace != ws || out.Branch != "feat/x" || len(out.Added) != 1 || out.Added[0].Status != "added" { + t.Fatalf("add = %+v", out) + } + if b := e.git(filepath.Join(ws, "worker"), "rev-parse", "--abbrev-ref", "HEAD"); b != "feat/x" { + t.Fatalf("worker branch = %s", b) + } + + if r := e.run(e.home, "add", "--no-input", "--repos", "worker"); r.code == 0 || !strings.Contains(r.stderr, "Not inside a spawnpoint workspace") { + t.Fatalf("outside workspace: %+v", r) + } +} + +func TestAddRestructuresSingleRepo(t *testing.T) { + goOnly(t) // Python keyed single-repo workspaces by folder, not repo, name. + e := newEnv(t) + e.ok("", "create", "--no-input", "--repos", "api", "--branch", "solo") + ws := filepath.Join(e.wsRoot, "solo") + + var out addOut + e.json(ws, &out, "add", "--no-input", "--json", "--repos", "web") + if len(out.Added) != 1 || out.Added[0].Name != "web" { + t.Fatalf("add = %+v", out) + } + for _, repo := range []string{"api", "web"} { + if b := e.git(filepath.Join(ws, repo), "rev-parse", "--abbrev-ref", "HEAD"); b != "solo" { + t.Fatalf("%s branch = %s", repo, b) + } + } + if !exists(filepath.Join(ws, "api", ".env")) { + t.Fatal("restructure lost untracked files") + } + // git in the parent repo must know the worktree's new location. + if list := e.git(filepath.Join(e.code, "api"), "worktree", "list"); !strings.Contains(list, filepath.Join(ws, "api")) { + t.Fatalf("worktree list:\n%s", list) + } + // And api is no longer offered. + if r := e.run(ws, "add", "--no-input", "--repos", "api"); r.code == 0 { + t.Fatal("api offered again after restructure") + } +} + +type tmplOut struct { + Name string `json:"name"` + Repos []string `json:"repos"` + Base *string `json:"base"` + Description *string `json:"description"` +} + +func TestTemplates(t *testing.T) { + e := newEnv(t) + var saved tmplOut + e.json("", &saved, "template", "save", "stack", "--no-input", "--json", "--repos", "api,worker", "--base", "main", "-d", "The stack") + if saved.Name != "stack" || strings.Join(saved.Repos, ",") != "api,worker" || *saved.Base != "main" || *saved.Description != "The stack" { + t.Fatalf("saved = %+v", saved) + } + + // Updating keeps base/description when omitted. + e.json("", &saved, "template", "save", "stack", "--no-input", "--json", "--repos", "api,web") + if strings.Join(saved.Repos, ",") != "api,web" || saved.Base == nil || *saved.Base != "main" { + t.Fatalf("updated = %+v", saved) + } + + var list []tmplOut + e.json("", &list, "template", "list", "--json") + if len(list) != 1 || list[0].Name != "stack" { + t.Fatalf("list = %+v", list) + } + var shown tmplOut + e.json("", &shown, "template", "show", "stack", "--json") + if shown.Name != "stack" { + t.Fatalf("show = %+v", shown) + } + + var out createOut + e.json("", &out, "create", "--no-input", "--json", "--template", "stack", "--branch", "feat/t") + if out.Template == nil || *out.Template != "stack" || len(out.Repos) != 2 { + t.Fatalf("create -t = %+v", out) + } + + var del map[string]string + e.json("", &del, "template", "delete", "stack", "--no-input", "--json") + if del["deleted"] != "stack" { + t.Fatalf("delete = %v", del) + } + e.json("", &list, "template", "list", "--json") + if len(list) != 0 { + t.Fatalf("list after delete = %+v", list) + } +} + +func TestTemplatesFileCompat(t *testing.T) { + // A templates.toml written by the Python release, including a name that + // needs escaping. + e := newEnv(t) + legacy := "# Spawnpoint workspace templates\n\n" + + "[templates.\"image-gen\"]\ndescription = \"Image generation stack\"\nrepos = [\"api\", \"web\"]\nbase = \"main\"\n\n" + + "[templates.\"we\\\"ird\"]\nrepos = [\"worker\"]\n" + os.WriteFile(filepath.Join(e.home, ".spawnpoint", "templates.toml"), []byte(legacy), 0o644) + var list []tmplOut + e.json("", &list, "template", "list", "--json") + if len(list) != 2 || list[0].Name != "image-gen" || list[1].Name != `we"ird` || list[1].Base != nil { + t.Fatalf("list = %+v", list) + } +} + +func TestCleanup(t *testing.T) { + goOnly(t) // Python printed status text to stdout here, breaking --json. + e := newEnv(t) + e.ok("", "create", "--no-input", "--repos", "api,web", "--branch", "feat/x") + e.ok("", "create", "--no-input", "--repos", "worker", "--branch", "keep") + + if r := e.run("", "cleanup", "--no-input", "--workspaces", "feat-x"); r.code == 0 || !strings.Contains(r.stderr, "--delete-branches or --keep-branches") { + t.Fatalf("missing branch pref: %+v", r) + } + + var out struct { + Removed []struct { + Workspace string + Worktrees []struct { + Repo string + Branch string + BranchDeleted bool `json:"branch_deleted"` + } + } + } + e.json("", &out, "cleanup", "--no-input", "--json", "--workspaces", "feat-x", "--delete-branches") + if len(out.Removed) != 1 || len(out.Removed[0].Worktrees) != 2 || !out.Removed[0].Worktrees[0].BranchDeleted { + t.Fatalf("cleanup = %+v", out) + } + if exists(filepath.Join(e.wsRoot, "feat-x")) { + t.Fatal("workspace dir still exists") + } + if b := e.git(filepath.Join(e.code, "api"), "branch", "--list", "feat/x"); b != "" { + t.Fatalf("branch not deleted: %q", b) + } + + e.json("", &out, "cleanup", "--no-input", "--json", "--workspaces", "keep", "--keep-branches") + if out.Removed[0].Worktrees[0].BranchDeleted { + t.Fatal("branch deleted despite --keep-branches") + } + if b := e.git(filepath.Join(e.code, "worker"), "branch", "--list", "keep"); b == "" { + t.Fatal("kept branch is gone") + } +} + +func TestLightCleanup(t *testing.T) { + goOnly(t) // Python printed status text to stdout here, breaking --json. + e := newEnv(t) + e.ok("", "create", "--no-input", "--repos", "api,web", "--branch", "feat/x") + ws := filepath.Join(e.wsRoot, "feat-x") + for _, d := range []string{"api/node_modules/pkg", "web/.venv/lib", "web/src/__pycache__"} { + os.MkdirAll(filepath.Join(ws, d), 0o755) + os.WriteFile(filepath.Join(ws, d, "blob"), bytes.Repeat([]byte("x"), 4096), 0o644) + } + + var out struct { + Freed int64 `json:"freed_bytes"` + Workspaces []struct { + Workspace string + Artifacts []struct { + Path string + OK bool + } + } + } + e.json("", &out, "light-cleanup", "--no-input", "--json", "--workspaces", "feat-x", "--artifact-types", "node_modules,.venv") + if out.Freed != 8192 || len(out.Workspaces[0].Artifacts) != 2 { + t.Fatalf("light-cleanup = %+v", out) + } + if exists(filepath.Join(ws, "api/node_modules")) || !exists(filepath.Join(ws, "web/src/__pycache__")) { + t.Fatal("wrong dirs deleted") + } + if !exists(filepath.Join(ws, "api/README.md")) { + t.Fatal("code was deleted") + } +} + +func TestHeadlessFirstRunWritesConfig(t *testing.T) { + e := newEnv(t) + os.Remove(filepath.Join(e.home, ".spawnpoint", "config.toml")) + var repos []struct{ Name string } + e.json("", &repos, "repos", "--json") + if len(repos) != 3 { + t.Fatalf("repos = %+v", repos) + } + cfg, err := os.ReadFile(filepath.Join(e.home, ".spawnpoint", "config.toml")) + if err != nil || !strings.Contains(string(cfg), "scan_dirs = ['~/code']") { + t.Fatalf("config = %s (%v)", cfg, err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..acdb213 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/mihirgupta0900/spawnpoint + +go 1.26.6 + +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.10 + charm.land/fang/v2 v2.0.1 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.6 + github.com/BurntSushi/toml v1.6.0 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/charmbracelet/x/term v0.2.2 + github.com/sahilm/fuzzy v0.1.3 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4f55c49 --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..cfdc3e7 --- /dev/null +++ b/install.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Install spawnpoint from GitHub releases. +# +# curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh +# +# Environment: +# SPAWNPOINT_VERSION version to install (default: latest), e.g. 1.0.0 +# SPAWNPOINT_INSTALL_DIR where to put the binary (default: ~/.local/bin) +# SPAWNPOINT_RELEASES_URL release base URL (default: GitHub; for mirrors/tests) +set -eu + +REPO="mihirgupta0900/spawnpoint" +BASE="${SPAWNPOINT_RELEASES_URL:-https://github.com/$REPO/releases}" +DIR="${SPAWNPOINT_INSTALL_DIR:-$HOME/.local/bin}" + +say() { printf '%s\n' "$*" >&2; } +die() { say "error: $*"; exit 1; } + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) die "unsupported OS $(uname -s); download a build from https://github.com/$REPO/releases" ;; +esac +case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) die "unsupported CPU $(uname -m)" ;; +esac + +if [ -n "${SPAWNPOINT_VERSION:-}" ]; then + url="$BASE/download/v${SPAWNPOINT_VERSION#v}" +else + url="$BASE/latest/download" +fi +asset="spawnpoint_${os}_${arch}.tar.gz" + +# An existing pipx/uv install owns ~/.local/bin/spawnpoint; upgrading it in +# place keeps a single install (it gets the same Go binary). +existing="$DIR/spawnpoint" +if [ -L "$existing" ] && [ -z "${SPAWNPOINT_FORCE:-}" ]; then + env_dir=$(dirname "$(dirname "$(readlink "$existing")")") + if [ -f "$env_dir/pipx_metadata.json" ]; then + die "spawnpoint is installed with pipx. Upgrade it with: pipx upgrade spawnpoint + (or run 'pipx uninstall spawnpoint' first, or set SPAWNPOINT_FORCE=1)" + elif [ -f "$env_dir/uv-receipt.toml" ]; then + die "spawnpoint is installed with uv. Upgrade it with: uv tool upgrade spawnpoint + (or run 'uv tool uninstall spawnpoint' first, or set SPAWNPOINT_FORCE=1)" + fi +fi + +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO "$2" "$1"; } +else + die "need curl or wget" +fi + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +say "Downloading $asset..." +fetch "$url/$asset" "$tmp/$asset" || die "download failed: $url/$asset" +fetch "$url/checksums.txt" "$tmp/checksums.txt" || die "download failed: $url/checksums.txt" + +want=$(awk -v f="$asset" '$2 == f { print $1 }' "$tmp/checksums.txt") +[ -n "$want" ] || die "no checksum for $asset" +if command -v sha256sum >/dev/null 2>&1; then + got=$(sha256sum "$tmp/$asset" | awk '{ print $1 }') +else + got=$(shasum -a 256 "$tmp/$asset" | awk '{ print $1 }') +fi +[ "$want" = "$got" ] || die "checksum mismatch for $asset" + +tar -xzf "$tmp/$asset" -C "$tmp" spawnpoint +mkdir -p "$DIR" +mv -f "$tmp/spawnpoint" "$DIR/spawnpoint" +chmod 755 "$DIR/spawnpoint" +ln -sf spawnpoint "$DIR/sp" + +say "Installed $("$DIR/spawnpoint" --version 2>/dev/null || echo spawnpoint) to $DIR (also as 'sp')" +case ":$PATH:" in + *":$DIR:"*) ;; + *) say "Add $DIR to your PATH, e.g.: echo 'export PATH=\"$DIR:\$PATH\"' >> ~/.zshrc" ;; +esac +say "Get started: sp init" diff --git a/internal/cli/add.go b/internal/cli/add.go new file mode 100644 index 0000000..b5692f2 --- /dev/null +++ b/internal/cli/add.go @@ -0,0 +1,147 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +func addCmd() *cobra.Command { + var ( + af agentFlags + repos, base string + ) + cmd := &cobra.Command{ + Use: "add", + Short: "Add repos to the current workspace", + Long: "Add repos to the workspace you're in, on the same branch. A single-repo\nworkspace is converted to the multi-repo layout automatically.", + Example: "cd ~/.spawnpoint/workspaces/feat-billing && sp add\nspawnpoint add --no-input --json --repos worker", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := ensureConfig(af) + if err != nil { + return err + } + return runAdd(cfg, af, repos, base) + }, + } + af.register(cmd, "never prompt; requires --repos") + cmd.Flags().StringVar(&repos, "repos", "", "comma-separated repo names to add") + cmd.Flags().StringVar(&base, "base", "", "base branch for new branches (default: each repo's default branch)") + return cmd +} + +func runAdd(cfg *config.Config, af agentFlags, reposArg, base string) error { + cwd, _ := os.Getwd() + ws, branch, ok := workspace.Detect(cwd, cfg.WorktreeDirs()) + if !ok { + ui.Error("Not inside a spawnpoint workspace.") + ui.Hint("Run %s to create one, or cd into an existing workspace first.", ui.Bold("spawnpoint create")) + return exit(1) + } + existing := workspace.RepoNamesIn(ws) + ui.Println(ui.Accent("Workspace ") + ui.Bold(filepath.Base(ws)) + ui.Dim(" on ") + ui.Accent(branch)) + if len(existing) > 0 { + ui.Hint("already here: %s", strings.Join(sortedKeys(existing), ", ")) + } + + all, _, err := scanRepos(cfg) + if err != nil { + return err + } + var candidates []workspace.Repo + for _, r := range all { + if !existing[r.Base()] { + candidates = append(candidates, r) + } + } + if len(candidates) == 0 { + ui.Warning("No other repositories to add.") + return nil + } + + var selected []workspace.Repo + if af.noInput { + if err := requireFlag(reposArg, "--repos"); err != nil { + return err + } + if selected, err = resolveRepos(parseCSV(reposArg), candidates, ""); err != nil { + return err + } + } else { + var pre []string + if reposArg != "" { + if selected, err = resolveRepos(parseCSV(reposArg), candidates, ""); err != nil { + return err + } + pre = repoNames(selected) + } + labels, err := ui.Pick(ui.PickOptions{Title: "Select repositories to add", Items: repoNames(candidates), Selected: pre, Multi: true}) + if err != nil { + return err + } + if len(labels) == 0 { + ui.Println("No repositories selected.") + return nil + } + selected = reposByName(candidates, labels) + } + + restructure := workspace.IsSingleRepo(ws) + prepare(selected) + + actions, err := planActions(selected, planOpts{ + branch: branch, base: base, noInput: af.noInput, + target: func(r workspace.Repo) string { return filepath.Join(ws, r.Base()) }, + }) + if err != nil { + return err + } + if len(actions) == 0 { + ui.Warning("Nothing to do.") + return nil + } + + if restructure { + ui.Blank() + ui.Println(ui.Warn("Note:") + " this single-repo workspace will switch to the multi-repo layout.") + ui.Hint("The existing worktree moves into %s/", filepath.Join(filepath.Base(ws), workspace.SingleRepoName(ws))) + } + printPlan(actions) + if !af.noInput { + ok, err := ui.Confirm("Proceed?", true) + if err != nil { + return err + } + if !ok { + ui.Println(ui.Dim("Aborted.")) + return nil + } + } + + if restructure { + name, err := workspace.Restructure(ws) + if err != nil { + return fail("restructuring workspace: %v", err) + } + ui.Success("Moved the existing worktree into %s/", name) + } + + build(actions, cfg, "added") + ui.Done("Workspace: " + ui.Accent(tilde(ws))) + + if af.json { + return emit(struct { + Workspace string `json:"workspace"` + Branch string `json:"branch"` + Added []repoResult `json:"added"` + }{ws, branch, results(actions)}) + } + return nil +} diff --git a/internal/cli/cleanup.go b/internal/cli/cleanup.go new file mode 100644 index 0000000..49f9940 --- /dev/null +++ b/internal/cli/cleanup.go @@ -0,0 +1,219 @@ +package cli + +import ( + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +const ( + branchesDelete = "Delete all branches" + branchesKeep = "Keep branches" + branchesAsk = "Ask per branch" +) + +func cleanupCmd() *cobra.Command { + var ( + af agentFlags + names string + deleteBranches, keepBranches bool + ) + cmd := &cobra.Command{ + Use: "cleanup", + Aliases: []string{"rm"}, + Short: "Remove workspaces (worktrees, folders and optionally branches)", + Example: "sp cleanup\nspawnpoint cleanup --no-input --json --workspaces feat-billing --delete-branches", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := ensureConfig(af) + if err != nil { + return err + } + var pref string + switch { + case deleteBranches && keepBranches: + return fail("Pass only one of --delete-branches / --keep-branches.") + case deleteBranches: + pref = branchesDelete + case keepBranches: + pref = branchesKeep + } + return runCleanup(cfg, af, names, pref) + }, + } + af.register(cmd, "never prompt; requires --workspaces and --delete-branches or --keep-branches") + cmd.Flags().StringVar(&names, "workspaces", "", "comma-separated workspace names (see `spawnpoint list`)") + cmd.Flags().BoolVar(&deleteBranches, "delete-branches", false, "also delete the branches from the parent repos") + cmd.Flags().BoolVar(&keepBranches, "keep-branches", false, "keep the branches in the parent repos") + return cmd +} + +// existingWorkspaces scans all worktree dirs, oldest first. +func existingWorkspaces(cfg *config.Config) []*workspace.Workspace { + var dirs []string + for _, d := range cfg.WorktreeDirs() { + if workspace.Exists(d) { + dirs = append(dirs, d) + } + } + all := workspace.ScanWorkspaces(dirs) + workspace.SortByAge(all, false) + return all +} + +type removedJSON struct { + Workspace string `json:"workspace"` + Worktrees []removedTree `json:"worktrees"` +} + +type removedTree struct { + Repo string `json:"repo"` + Branch string `json:"branch"` + BranchDeleted bool `json:"branch_deleted"` +} + +func runCleanup(cfg *config.Config, af agentFlags, names, pref string) error { + all := existingWorkspaces(cfg) + if len(all) == 0 { + if af.json { + return emit(map[string][]removedJSON{"removed": {}}) + } + ui.Warning("No workspaces found.") + return nil + } + + var selected []*workspace.Workspace + var err error + if af.noInput { + if err := requireFlag(names, "--workspaces"); err != nil { + return err + } + if selected, err = resolveWorkspaces(parseCSV(names), all); err != nil { + return err + } + if pref == "" { + return fail("--no-input requires --delete-branches or --keep-branches.") + } + } else { + labels := make([]string, len(all)) + byLabel := map[string]*workspace.Workspace{} + var pre []string + for i, ws := range all { + labels[i] = ws.Label() + byLabel[labels[i]] = ws + } + for _, n := range parseCSV(names) { + for _, ws := range all { + if ws.Name == n { + pre = append(pre, ws.Label()) + } + } + } + picked, err := ui.Pick(ui.PickOptions{Title: "Select workspaces to remove", Items: labels, Selected: pre, Multi: true}) + if err != nil { + return err + } + if len(picked) == 0 { + ui.Println("No workspaces selected.") + return nil + } + for _, l := range picked { + selected = append(selected, byLabel[l]) + } + if pref == "" { + if pref, err = ui.Select("Delete branches from parent repos?", []string{branchesDelete, branchesKeep, branchesAsk}, branchesDelete); err != nil { + return err + } + } + } + + ui.Blank() + ui.Header("Plan") + for _, ws := range selected { + ui.Println(" " + ui.Bold(ws.Name+"/")) + for _, wt := range ws.Worktrees { + line := " " + wt.ParentName() + ": remove worktree" + if wt.Dirty { + line += " " + ui.Warn("(dirty — will force)") + } + switch pref { + case branchesDelete: + line += ui.Dim(", delete branch " + wt.Branch) + case branchesAsk: + line += ui.Dim(", ask about " + wt.Branch) + } + ui.Println(line) + } + } + ui.Blank() + if !af.noInput { + ok, err := ui.Confirm("Proceed with cleanup?", false) + if err != nil { + return err + } + if !ok { + ui.Println(ui.Dim("Aborted.")) + return nil + } + } + + report := []removedJSON{} + parents := map[string]bool{} + for _, ws := range selected { + ui.Println(ui.Bold(ws.Name)) + decisions := map[string]bool{} + trees := []removedTree{} + for _, wt := range ws.Worktrees { + del := pref == branchesDelete + if pref == branchesAsk { + key := wt.Parent + ":" + wt.Branch + if _, asked := decisions[key]; !asked { + if decisions[key], err = ui.Confirm("Delete branch '"+wt.Branch+"' from "+wt.ParentName()+"?", true); err != nil { + return err + } + } + del = decisions[key] + } + deleted, notes := workspace.RemoveWorktree(wt, del) + line := " " + ui.Good("✓") + " removed " + wt.ParentName() + if deleted { + line += ui.Dim(" + branch " + wt.Branch) + } + ui.Println(line) + for _, n := range notes { + ui.Hint("%s", ui.Warn(n)) + } + trees = append(trees, removedTree{wt.ParentName(), wt.Branch, deleted}) + if wt.Parent != "" && workspace.Exists(wt.Parent) { + parents[wt.Parent] = true + } + } + os.RemoveAll(ws.Path) + report = append(report, removedJSON{ws.Name, trees}) + } + + keys := make([]string, 0, len(parents)) + for p := range parents { + keys = append(keys, p) + } + sort.Strings(keys) + for _, p := range keys { + workspace.Git(p, "worktree", "prune") + } + + names = "" + for _, r := range report { + names += r.Workspace + " " + } + ui.Done(ui.Dim("Removed " + strings.TrimSpace(names))) + if af.json { + return emit(map[string][]removedJSON{"removed": report}) + } + return nil +} diff --git a/internal/cli/create.go b/internal/cli/create.go new file mode 100644 index 0000000..2a2a572 --- /dev/null +++ b/internal/cli/create.go @@ -0,0 +1,305 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +func createCmd() *cobra.Command { + var ( + af agentFlags + yes bool + repos, branch, base, template, saveTemplate string + ) + cmd := &cobra.Command{ + Use: "create", + Short: "Pick repos and spawn a workspace for a branch", + Long: "Pick repos and spawn a workspace: one git worktree per repo on the same branch,\nwith env files copied and dependencies installed.", + Example: strings.Join([]string{ + "sp create", + "sp create -t billing", + "spawnpoint create --no-input --json --repos api,web --branch feat/billing", + }, "\n"), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := ensureConfig(af) + if err != nil { + return err + } + return runCreate(cfg, createOpts{af, yes, repos, branch, base, template, saveTemplate}) + }, + } + f := cmd.Flags() + f.BoolVarP(&yes, "yes", "y", false, "use each repo's default branch as the base without asking") + af.register(cmd, "never prompt; requires --branch and --repos or --template") + f.StringVar(&repos, "repos", "", "comma-separated repo names (see `spawnpoint repos`)") + f.StringVarP(&branch, "branch", "b", "", "branch name") + f.StringVar(&base, "base", "", "base branch for new branches (default: each repo's default branch)") + f.StringVarP(&template, "template", "t", "", "start from a saved template's repos") + f.StringVar(&saveTemplate, "save-template", "", "save the selected repos as a template with this name") + return cmd +} + +type createOpts struct { + agentFlags + yes bool + repos, branch, base, template, saveTemplate string +} + +func runCreate(cfg *config.Config, o createOpts) error { + // Validate a named template before the repo scan so typos fail fast. + var active *config.Template + if o.template != "" { + t, err := getTemplate(o.template) + if err != nil { + return err + } + active = t + } + + all, _, err := scanRepos(cfg) + if err != nil { + return err + } + if len(all) == 0 { + ui.Warning("No git repositories found.") + return nil + } + ui.Println(ui.Dim(fmt.Sprintf("Found %d repos in %s", len(all), strings.Join(tildeAll(cfg.ValidScanDirs()), ", ")))) + + // Offer saved templates as a starting point when nothing was passed by flag. + if active == nil && !o.noInput && o.repos == "" { + templates, err := config.LoadTemplates() + if err != nil { + return fail("%v", err) + } + if len(templates) > 0 { + if active, err = promptForTemplate(templates); err != nil { + return err + } + } + } + + if active != nil { + ui.Println(ui.Accent("Template ") + ui.Bold(active.Name)) + if o.repos != "" { + ui.Hint("--repos overrides its repo list") + } else { + ui.Hint("repos: %s", strings.Join(active.Repos, ", ")) + } + if o.base == "" && active.Base != nil { + o.base = *active.Base + ui.Hint("base branch: %s", o.base) + } + } + + var selected []workspace.Repo + if o.noInput { + switch { + case o.repos != "": + // An explicit --repos wins over the template's repo list. + selected, err = resolveRepos(parseCSV(o.repos), all, "") + case active != nil: + selected, err = resolveRepos(active.Repos, all, templateRepoHint(active.Name)) + default: + return fail("--no-input requires --repos or --template.") + } + if err != nil { + return err + } + if len(selected) == 0 { + return fail("No repositories to spawn.") + } + } else { + var pre []string + if active != nil { + pre = preselectTemplateRepos(active, all) + } else if o.repos != "" { + if selected, err = resolveRepos(parseCSV(o.repos), all, ""); err != nil { + return err + } + pre = repoNames(selected) + } + labels, err := ui.Pick(ui.PickOptions{Title: "Select repositories", Items: repoNames(all), Selected: pre, Multi: true}) + if err != nil { + return err + } + if len(labels) == 0 { + ui.Println("No repositories selected.") + return nil + } + selected = reposByName(all, labels) + } + + branch := o.branch + if o.noInput { + if err := requireFlag(branch, "--branch"); err != nil { + return err + } + } else if branch == "" { + if branch, err = ui.Input("Branch name:", ""); err != nil { + return err + } + if branch == "" { + return fail("Branch name cannot be empty.") + } + } + + prepare(selected) + + if err := os.MkdirAll(cfg.WorktreeDir, 0o755); err != nil { + return fail("%v", err) + } + root := resolveDir(cfg.WorktreeDir) + dirName := strings.ReplaceAll(branch, "/", "-") + single := len(selected) == 1 + actions, err := planActions(selected, planOpts{ + branch: branch, base: o.base, yes: o.yes, noInput: o.noInput, + target: func(r workspace.Repo) string { + if single { + return filepath.Join(root, dirName) + } + return filepath.Join(root, dirName, r.Base()) + }, + }) + if err != nil { + return err + } + if len(actions) == 0 { + ui.Warning("Nothing to do.") + return nil + } + + printPlan(actions) + if !o.noInput { + ok, err := ui.Confirm("Proceed?", true) + if err != nil { + return err + } + if !ok { + ui.Println(ui.Dim("Aborted.")) + return nil + } + } + + // Remember this repo set for next time. + names := repoNames(selected) + save := o.saveTemplate + if save == "" && !o.noInput { + if save, err = offerSaveTemplate(names, active); err != nil { + return err + } + } + if save != "" { + t, err := config.MergeTemplate(save, names, o.base, "") + if err != nil { + return fail("%v", err) + } + path, existed, err := config.UpsertTemplate(t) + if err != nil { + return fail("%v", err) + } + verb := "Saved" + if existed { + verb = "Updated" + } + ui.Success("%s template '%s' %s", verb, save, ui.Dim("("+tilde(path)+")")) + ui.Hint("Reuse it with: spawnpoint create -t %s", save) + } + + build(actions, cfg, "created") + + wsPath := filepath.Join(root, dirName) + if single { + wsPath = actions[0].Target + } + ui.Done("Workspace: " + ui.Accent(tilde(wsPath))) + writeCDPath(wsPath) + + if o.json { + var tmpl *string + if active != nil { + tmpl = &active.Name + } + return emit(struct { + Workspace string `json:"workspace"` + Branch string `json:"branch"` + Template *string `json:"template"` + Repos []repoResult `json:"repos"` + }{wsPath, branch, tmpl, results(actions)}) + } + if o.noInput { + // Plain stdout so agents can capture the workspace path via $(...). + fmt.Println(wsPath) + } + return nil +} + +// offerSaveTemplate offers to remember an interactively picked repo set. +// Skipped for a single repo and when the set still matches its template. +func offerSaveTemplate(names []string, active *config.Template) (string, error) { + if len(names) < 2 { + return "", nil + } + if active != nil && sameSet(names, active.Repos) { + return "", nil + } + ok, err := ui.Confirm(fmt.Sprintf("Save these %d repos as a template for next time?", len(names)), false) + if err != nil || !ok { + return "", err + } + name, err := ui.Input("Template name:", "") + if err != nil { + return "", err + } + if name == "" { + ui.Println(ui.Dim("No name given, template not saved.")) + return "", nil + } + templates, err := config.LoadTemplates() + if err != nil { + return "", fail("%v", err) + } + if _, exists := templates[name]; exists { + ok, err := ui.Confirm(fmt.Sprintf("Template '%s' already exists. Overwrite it?", name), false) + if err != nil { + return "", err + } + if !ok { + ui.Println(ui.Dim("Template not saved.")) + return "", nil + } + } + return name, nil +} + +func sameSet(a, b []string) bool { + a, b = slices.Clone(a), slices.Clone(b) + slices.Sort(a) + slices.Sort(b) + return slices.Equal(slices.Compact(a), slices.Compact(b)) +} + +func tildeAll(ps []string) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = tilde(p) + } + return out +} + +func resolveDir(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + return p +} diff --git a/internal/cli/light_cleanup.go b/internal/cli/light_cleanup.go new file mode 100644 index 0000000..ed107a0 --- /dev/null +++ b/internal/cli/light_cleanup.go @@ -0,0 +1,227 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +func lightCleanupCmd() *cobra.Command { + var ( + af agentFlags + names, types string + ) + cmd := &cobra.Command{ + Use: "light-cleanup", + Short: "Free disk space: delete node_modules, .venv, build caches (keeps code)", + Long: "Delete reinstallable directories (node_modules, .venv, .next, target, …) from\n" + + "workspaces to free disk space. Code, branches and worktrees are untouched.", + Example: "sp light-cleanup\nspawnpoint light-cleanup --no-input --json --workspaces feat-billing --artifact-types node_modules", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := ensureConfig(af) + if err != nil { + return err + } + return runLightCleanup(cfg, af, names, types) + }, + } + af.register(cmd, "never prompt; requires --workspaces") + cmd.Flags().StringVar(&names, "workspaces", "", "comma-separated workspace names") + cmd.Flags().StringVar(&types, "artifact-types", "", "comma-separated dir names to delete, e.g. node_modules,.venv (default: all found)") + return cmd +} + +type deletedArtifact struct { + Path string `json:"path"` + Size int64 `json:"size_bytes"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type lightReport struct { + Workspace string `json:"workspace"` + Artifacts []deletedArtifact `json:"artifacts"` +} + +func runLightCleanup(cfg *config.Config, af agentFlags, names, types string) error { + emptyJSON := func() error { + if af.json { + return emit(map[string]any{"freed_bytes": 0, "workspaces": []lightReport{}}) + } + return nil + } + all := existingWorkspaces(cfg) + if len(all) == 0 { + ui.Warning("No workspaces found.") + return emptyJSON() + } + + var selected []*workspace.Workspace + var err error + if af.noInput { + if err := requireFlag(names, "--workspaces"); err != nil { + return err + } + if selected, err = resolveWorkspaces(parseCSV(names), all); err != nil { + return err + } + } else { + labels := make([]string, len(all)) + byLabel := map[string]*workspace.Workspace{} + for i, ws := range all { + labels[i] = ws.Label() + byLabel[labels[i]] = ws + } + picked, err := ui.Pick(ui.PickOptions{Title: "Select workspaces for light cleanup", Items: labels, Multi: true}) + if err != nil { + return err + } + if len(picked) == 0 { + ui.Println("No workspaces selected.") + return nil + } + for _, l := range picked { + selected = append(selected, byLabel[l]) + } + } + + type found struct { + ws *workspace.Workspace + arts []workspace.Artifact + } + var results []found + ui.Spin("Measuring reinstallable directories…", func() { + for _, ws := range selected { + var arts []workspace.Artifact + for _, wt := range ws.Worktrees { + arts = append(arts, workspace.FindArtifacts(wt.Path)...) + } + if len(arts) > 0 { + workspace.MeasureSizes(arts) + results = append(results, found{ws, arts}) + } + } + }) + if len(results) == 0 { + ui.Success("Nothing to clean: no reinstallable directories found.") + return emptyJSON() + } + + totals := map[string]int64{} + for _, r := range results { + for _, a := range r.arts { + totals[a.Type] += a.Size + } + } + + chosen := map[string]bool{} + if af.noInput { + want := map[string]bool{} + for _, t := range parseCSV(types) { + want[t] = true + } + for t := range totals { + if len(want) == 0 || want[t] { + chosen[t] = true + } + } + } else { + var labels, values []string + for _, at := range workspace.ArtifactTypes { + if size, ok := totals[at.Name]; ok { + labels = append(labels, fmt.Sprintf("%-14s %s %s", at.Name, ui.Dim(at.Desc), ui.Accent(workspace.FormatBytes(size)))) + values = append(values, at.Name) + } + } + picked, err := ui.Checklist("Which directories should go?", labels, values) + if err != nil { + return err + } + if len(picked) == 0 { + ui.Println("Nothing selected.") + return nil + } + for _, t := range picked { + chosen[t] = true + } + } + + var total int64 + var plan []found + for _, r := range results { + var keep []workspace.Artifact + for _, a := range r.arts { + if chosen[a.Type] { + keep = append(keep, a) + total += a.Size + } + } + if len(keep) > 0 { + sort.SliceStable(keep, func(i, j int) bool { return keep[i].Size > keep[j].Size }) + plan = append(plan, found{r.ws, keep}) + } + } + if len(plan) == 0 { + ui.Warning("No matching directories after filtering.") + return emptyJSON() + } + + ui.Blank() + ui.Header("Plan · frees ~" + workspace.FormatBytes(total)) + for _, r := range plan { + var sum int64 + for _, a := range r.arts { + sum += a.Size + } + ui.Println(" " + ui.Bold(r.ws.Name+"/") + ui.Dim(" ~"+workspace.FormatBytes(sum))) + for _, a := range r.arts { + rel, _ := filepath.Rel(r.ws.Path, a.Path) + ui.Println(" " + ui.Dim(rel) + " " + workspace.FormatBytes(a.Size)) + } + } + ui.Blank() + if !af.noInput { + ok, err := ui.Confirm("Proceed with light cleanup?", true) + if err != nil { + return err + } + if !ok { + ui.Println(ui.Dim("Aborted.")) + return nil + } + } + + var freed int64 + report := []lightReport{} + for _, r := range plan { + entry := lightReport{Workspace: r.ws.Name, Artifacts: []deletedArtifact{}} + for _, a := range r.arts { + rel, _ := filepath.Rel(r.ws.Path, a.Path) + if err := os.RemoveAll(a.Path); err != nil { + ui.Println(" " + ui.Bad("✗") + " " + rel + ": " + err.Error()) + entry.Artifacts = append(entry.Artifacts, deletedArtifact{a.Path, a.Size, false, err.Error()}) + continue + } + freed += a.Size + ui.Println(" " + ui.Good("✓") + " " + rel + ui.Dim(" "+workspace.FormatBytes(a.Size))) + entry.Artifacts = append(entry.Artifacts, deletedArtifact{Path: a.Path, Size: a.Size, OK: true}) + } + report = append(report, entry) + } + ui.Done("Freed ~" + workspace.FormatBytes(freed) + ".") + if af.json { + return emit(struct { + Freed int64 `json:"freed_bytes"` + Workspaces []lightReport `json:"workspaces"` + }{freed, report}) + } + return nil +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..afcedd4 --- /dev/null +++ b/internal/cli/list.go @@ -0,0 +1,160 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +func listCmd() *cobra.Command { + var ( + af agentFlags + cd bool + name string + ) + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List workspaces (or pick one to cd into)", + Example: "sp list\nsp list --cd\nspawnpoint list --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := ensureConfig(af) + if err != nil { + return err + } + return runList(cfg, af, cd, name) + }, + } + cmd.Flags().BoolVarP(&cd, "cd", "c", false, "pick a workspace to cd into (with shell integration)") + af.register(cmd, "never prompt; with --cd, requires --workspace") + cmd.Flags().StringVar(&name, "workspace", "", "workspace to cd into (with --cd --no-input)") + return cmd +} + +type workspaceJSON struct { + Name string `json:"name"` + Path string `json:"path"` + Repos int `json:"repos"` + Branches []string `json:"branches"` + Dirty bool `json:"dirty"` +} + +func workspacePayload(ws *workspace.Workspace) workspaceJSON { + return workspaceJSON{ws.Name, ws.Path, len(ws.Worktrees), ws.Branches(), ws.Dirty()} +} + +func runList(cfg *config.Config, af agentFlags, cd bool, name string) error { + all := workspace.ScanWorkspaces(cfg.WorktreeDirs()) + workspace.SortByAge(all, true) + + if len(all) == 0 { + if af.json { + return emit([]workspaceJSON{}) + } + ui.Warning("No workspaces yet. Create one with %s.", ui.Bold("sp create")) + return nil + } + + if !cd { + if af.json { + out := make([]workspaceJSON, len(all)) + for i, ws := range all { + out[i] = workspacePayload(ws) + } + return emit(out) + } + rows := make([][]string, len(all)) + for i, ws := range all { + status := ui.Good("clean") + if ws.Dirty() { + status = ui.Warn("dirty") + } + rows[i] = []string{ + ui.Bold(ws.Name), strconv.Itoa(len(ws.Worktrees)), strings.Join(ws.Branches(), ", "), + status, ui.Dim(workspace.FormatAge(ws.Oldest())), + } + } + ui.Table([]string{"Workspace", "Repos", "Branch", "Status", "Modified"}, rows) + return nil + } + + var chosen *workspace.Workspace + if af.noInput { + if err := requireFlag(name, "--workspace"); err != nil { + return err + } + picked, err := resolveWorkspaces([]string{name}, all) + if err != nil { + return err + } + chosen = picked[0] + } else { + labels := make([]string, len(all)) + byLabel := map[string]*workspace.Workspace{} + for i, ws := range all { + labels[i] = ws.Label() + byLabel[labels[i]] = ws + } + picked, err := ui.Pick(ui.PickOptions{Title: "Select workspace", Items: labels}) + if err != nil { + return err + } + if len(picked) == 0 { + return nil + } + chosen = byLabel[picked[0]] + } + + writeCDPath(chosen.Path) + switch { + case af.json: + return emit(workspacePayload(chosen)) + case af.noInput: + fmt.Println(chosen.Path) + } + return nil +} + +func reposCmd() *cobra.Command { + var af agentFlags + cmd := &cobra.Command{ + Use: "repos", + Short: "List repos you can pick (names for --repos)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := ensureConfig(af) + if err != nil { + return err + } + if len(cfg.ValidScanDirs()) == 0 { + return fail("No scan directories configured. Run %s.", ui.Bold("spawnpoint init")) + } + repos := workspace.FindRepos(cfg.ValidScanDirs(), cfg.ScanDepth) + if af.json { + if repos == nil { + repos = []workspace.Repo{} + } + return emit(repos) + } + if len(repos) == 0 { + ui.Warning("No git repositories found.") + return nil + } + rows := make([][]string, len(repos)) + for i, r := range repos { + rows[i] = []string{ui.Bold(r.Name), ui.Dim(tilde(r.Path))} + } + ui.Table([]string{"Name", "Path"}, rows) + return nil + }, + } + af.register(cmd, "") + return cmd +} diff --git a/internal/cli/resolve.go b/internal/cli/resolve.go new file mode 100644 index 0000000..5164899 --- /dev/null +++ b/internal/cli/resolve.go @@ -0,0 +1,137 @@ +package cli + +import ( + "strings" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +// resolveRepos maps requested names onto repos, by display name or bare +// directory name. Unknown or ambiguous names exit 1, listing valid choices so +// an agent can correct its call. hint is an extra line printed on failure. +func resolveRepos(requested []string, repos []workspace.Repo, hint string) ([]workspace.Repo, error) { + byName := map[string]workspace.Repo{} + byBase := map[string][]string{} + for _, r := range repos { + byName[r.Name] = r + byBase[r.Base()] = append(byBase[r.Base()], r.Name) + } + var out []workspace.Repo + for _, name := range requested { + if r, ok := byName[name]; ok { + out = append(out, r) + continue + } + if matches, ok := byBase[name]; ok { + if len(matches) > 1 { + ui.Error("repo '%s' is ambiguous; matches: %s. Use the full name.", name, strings.Join(matches, ", ")) + if hint != "" { + ui.Println(hint) + } + return nil, exit(1) + } + out = append(out, byName[matches[0]]) + continue + } + ui.Error("repo '%s' not found. Valid: %s", name, orNone(sortedKeys(byName))) + if hint != "" { + ui.Println(hint) + } + return nil, exit(1) + } + return out, nil +} + +// resolveWorkspaces maps names onto scanned workspaces. +func resolveWorkspaces(requested []string, all []*workspace.Workspace) ([]*workspace.Workspace, error) { + byName := map[string]*workspace.Workspace{} + for _, ws := range all { + byName[ws.Name] = ws + } + var out []*workspace.Workspace + for _, name := range requested { + ws, ok := byName[name] + if !ok { + return nil, fail("workspace '%s' not found. Valid: %s", name, orNone(sortedKeys(byName))) + } + out = append(out, ws) + } + return out, nil +} + +// scanRepos returns repos under the valid scan dirs, or exits explaining why +// there are none. +func scanRepos(cfg *config.Config) ([]workspace.Repo, []string, error) { + valid := cfg.ValidScanDirs() + if len(cfg.ScanDirs) == 0 { + ui.Error("No scan directories configured.") + ui.Hint("Run %s to set up.", ui.Bold("spawnpoint init")) + return nil, nil, exit(1) + } + if len(valid) == 0 { + ui.Error("None of your scan directories exist:") + for _, d := range cfg.ScanDirs { + ui.Hint("%s", d) + } + return nil, nil, exit(1) + } + return workspace.FindRepos(valid, cfg.ScanDepth), valid, nil +} + +// templateRepoHint explains a bad repo name that came from a template. +func templateRepoHint(name string) string { + return "Those repo names came from template '" + name + "'. Update it with " + + ui.Bold("spawnpoint template save "+name+" --repos ...") + "." +} + +// preselectTemplateRepos maps a template's repo names onto picker labels, +// warning about (and skipping) repos that no longer exist. +func preselectTemplateRepos(t *config.Template, repos []workspace.Repo) []string { + label := map[string]string{} + for _, r := range repos { + label[r.Name] = r.Name + } + for _, r := range repos { + if _, ok := label[r.Base()]; !ok { + label[r.Base()] = r.Name + } + } + var labels, missing []string + seen := map[string]bool{} + for _, name := range t.Repos { + l, ok := label[name] + switch { + case !ok: + missing = append(missing, name) + case !seen[l]: + seen[l] = true + labels = append(labels, l) + } + } + if len(missing) > 0 { + ui.Warning("Template '%s' lists repos that are no longer available: %s", t.Name, strings.Join(missing, ", ")) + } + return labels +} + +func repoNames(repos []workspace.Repo) []string { + out := make([]string, len(repos)) + for i, r := range repos { + out[i] = r.Name + } + return out +} + +func reposByName(repos []workspace.Repo, names []string) []workspace.Repo { + byName := map[string]workspace.Repo{} + for _, r := range repos { + byName[r.Name] = r + } + out := make([]workspace.Repo, 0, len(names)) + for _, n := range names { + out = append(out, byName[n]) + } + return out +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..0872b7d --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,244 @@ +// Package cli wires the spawnpoint commands together with cobra and fang. +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "image/color" + "io" + "log/slog" + "os" + "slices" + "strings" + + "charm.land/fang/v2" + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/update" +) + +// exitError ends the command with a status code after the command has +// already explained what went wrong. +type exitError struct{ code int } + +func (e exitError) Error() string { return fmt.Sprintf("exit %d", e.code) } + +func exit(code int) error { return exitError{code} } + +// fail prints an error line (plus optional hint lines) and exits 1. +func fail(format string, a ...any) error { + ui.Error(format, a...) + return exit(1) +} + +// Execute runs the CLI and returns the process exit code. +func Execute(version string) int { + root := newRoot() + checker := update.NewChecker(version) + root.PersistentPreRun = func(cmd *cobra.Command, _ []string) { + level := slog.LevelWarn + if debug, _ := cmd.Flags().GetBool("debug"); debug { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + if skipUpdateCheck(cmd) { + return + } + if cfg, err := config.Load(); err == nil && cfg.CheckUpdates { + checker.Start() + } + } + + err := fang.Execute(context.Background(), root, + fang.WithVersion(version), + fang.WithColorSchemeFunc(colorScheme), + fang.WithErrorHandler(errorHandler), + ) + if notice := checker.Notice(); notice != "" { + ui.Println(notice) + } + + var ee exitError + switch { + case err == nil: + return 0 + case errors.As(err, &ee): + return ee.code + case errors.Is(err, ui.ErrAborted): + return 130 + default: + return 1 + } +} + +// skipUpdateCheck keeps agents' output clean and avoids checking during +// `update` itself. +func skipUpdateCheck(cmd *cobra.Command) bool { + if cmd.Name() == "update" || cmd.Name() == "completion" || cmd.Name() == "man" { + return true + } + for _, name := range []string{"json", "no-input"} { + if f := cmd.Flags().Lookup(name); f != nil && f.Changed { + return true + } + } + return false +} + +func errorHandler(w io.Writer, styles fang.Styles, err error) { + var ee exitError + switch { + case errors.As(err, &ee): + return + case errors.Is(err, ui.ErrAborted): + ui.Println(ui.Dim("Aborted.")) + return + } + fang.DefaultErrorHandler(w, styles, err) +} + +func colorScheme(ld lipgloss.LightDarkFunc) fang.ColorScheme { + cs := fang.DefaultColorScheme(ld) + cs.Title = ui.Blue + cs.Command = ui.Purple + cs.Program = ui.Blue + cs.Flag = ui.Green + cs.Argument = ld(lipgloss.Color("#1F2328"), lipgloss.Color("#E6EDF3")) + cs.DimmedArgument = ui.Gray + cs.Comment = ui.Gray + cs.QuotedString = ui.Yellow + cs.ErrorHeader = [2]color.Color{lipgloss.Color("#FFFFFF"), ui.Red} + return cs +} + +func newRoot() *cobra.Command { + root := &cobra.Command{ + Use: "spawnpoint", + Short: "One branch. Every repo. Ready to code.", + Long: "Spawnpoint gives every task its own folder: a git worktree for each repo it\n" + + "touches, all on the same branch, with .env files copied and dependencies\n" + + "installed. Point Claude Code, Codex, or yourself at it and start working.", + Example: strings.Join([]string{ + "# pick repos, name a branch, spawn", + "sp create", + "", + "# from a script or agent", + "spawnpoint create --no-input --json --repos api,web --branch feat/billing", + }, "\n"), + SilenceUsage: true, + } + root.PersistentFlags().Bool("debug", false, "enable debug logging") + + root.AddGroup( + &cobra.Group{ID: "work", Title: "Workspaces"}, + &cobra.Group{ID: "setup", Title: "Setup"}, + ) + for _, c := range []*cobra.Command{ + createCmd(), addCmd(), listCmd(), reposCmd(), templateCmd(), cleanupCmd(), lightCleanupCmd(), + } { + c.GroupID = "work" + root.AddCommand(c) + } + for _, c := range []*cobra.Command{initCmd(), configCmd(), updateCmd()} { + c.GroupID = "setup" + root.AddCommand(c) + } + root.SetHelpCommandGroupID("setup") + root.SetCompletionCommandGroupID("setup") + cobra.EnableCommandSorting = false + return root +} + +// agentFlags are shared by every command that can run headless. +type agentFlags struct { + noInput bool + json bool +} + +func (a *agentFlags) register(cmd *cobra.Command, noInputHelp string) { + if noInputHelp != "" { + cmd.Flags().BoolVarP(&a.noInput, "no-input", "n", false, noInputHelp) + } + cmd.Flags().BoolVar(&a.json, "json", false, "emit machine-readable JSON to stdout") +} + +// emit prints v as indented JSON on stdout. +func emit(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +// ensureConfig loads the config, running first-time setup if none exists. +// Headless callers get a config written from detected defaults instead of a +// prompt, so agents never hang. +func ensureConfig(a agentFlags) (*config.Config, error) { + if !config.Exists() { + if a.noInput || a.json { + cfg := config.Default() + cfg.ScanDirs = config.DetectScanDirs() + if _, err := config.Save(cfg); err != nil { + return nil, err + } + return config.Load() + } + ui.Println(ui.Bold("Welcome to Spawnpoint!") + " Let's set things up.") + ui.Blank() + if err := runInit(); err != nil { + return nil, err + } + } + cfg, err := config.Load() + if err != nil { + return nil, fail("%v", err) + } + return cfg, nil +} + +// parseCSV splits a comma-separated flag into trimmed, non-empty parts. +func parseCSV(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func requireFlag(value, flag string) error { + if strings.TrimSpace(value) == "" { + return fail("--no-input requires %s.", flag) + } + return nil +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + +func orNone(names []string) string { + if len(names) == 0 { + return "(none)" + } + return strings.Join(names, ", ") +} + +// tilde shortens paths under $HOME for display. +func tilde(p string) string { + if h, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, h+string(os.PathSeparator)) { + return "~" + p[len(h):] + } + return p +} diff --git a/internal/cli/setup.go b/internal/cli/setup.go new file mode 100644 index 0000000..f72de34 --- /dev/null +++ b/internal/cli/setup.go @@ -0,0 +1,256 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" +) + +func initCmd() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Interactive setup: scan dirs, workspace location, shell integration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if config.Exists() { + ok, err := ui.Confirm(fmt.Sprintf("Config already exists at %s. Overwrite?", tilde(config.Path())), false) + if err != nil { + return err + } + if !ok { + ui.Println("Keeping existing config.") + return offerShellIntegration() + } + } + return runInit() + }, + } +} + +func runInit() error { + cfg := config.Default() + + if detected := config.DetectScanDirs(); len(detected) > 0 { + ui.Println("Found these code directories:") + for _, d := range detected { + ui.Println(" " + ui.Good("✓") + " " + tilde(d)) + } + ui.Blank() + ok, err := ui.Confirm("Use these as scan directories?", true) + if err != nil { + return err + } + if ok { + cfg.ScanDirs = detected + } + } + + extras, err := ui.Input("Add any other directories? (comma-separated, enter to skip)", "") + if err != nil { + return err + } + for _, d := range parseCSV(extras) { + p := config.ExpandPath(d) + if info, err := os.Stat(p); err != nil || !info.IsDir() { + ui.Warning("Skipping %s (does not exist)", d) + continue + } + if !slices.Contains(cfg.ScanDirs, p) { + cfg.ScanDirs = append(cfg.ScanDirs, p) + } + } + if len(cfg.ScanDirs) == 0 { + ui.Warning("No scan directories set. You can add them later in the config.") + } + + wt, err := ui.Input("Where should workspaces be created?", tilde(filepath.Join(config.Dir(), "workspaces"))) + if err != nil { + return err + } + cfg.WorktreeDir = config.ExpandPath(wt) + + if cfg.AutoInstallDeps, err = ui.Confirm("Auto-install dependencies after creating worktrees?", true); err != nil { + return err + } + + path, err := config.Save(cfg) + if err != nil { + return fail("saving config: %v", err) + } + ui.Blank() + ui.Success("Config saved to %s", tilde(path)) + ui.Hint("Run %s to view or edit it later.", ui.Bold("spawnpoint config")) + ui.Blank() + return offerShellIntegration() +} + +const shellMarker = "spawnpoint shell integration" + +const posixSnippet = ` +# spawnpoint shell integration +sp() { + local cmd="${1:-create}" + shift 2>/dev/null + local cd_file="$HOME/.spawnpoint/.cd_path" + rm -f "$cd_file" + case "$cmd" in + create) spawnpoint create "$@" ;; + list|ls) spawnpoint list --cd "$@" ;; + *) spawnpoint "$cmd" "$@" ;; + esac + if [ -f "$cd_file" ]; then + local dir=$(cat "$cd_file") + rm -f "$cd_file" + [ -n "$dir" ] && cd "$dir" + fi +} +` + +const fishSnippet = ` +# spawnpoint shell integration +function sp + set cmd (test (count $argv) -gt 0; and echo $argv[1]; or echo create) + set rest $argv[2..] + set cd_file "$HOME/.spawnpoint/.cd_path" + rm -f $cd_file + switch $cmd + case create + spawnpoint create $rest + case list ls + spawnpoint list --cd $rest + case '*' + spawnpoint $cmd $rest + end + if test -f $cd_file + set dir (cat $cd_file) + rm -f $cd_file + test -n "$dir"; and cd $dir + end +end +` + +func shellRCFiles() []string { + h, _ := os.UserHomeDir() + j := func(p ...string) string { return filepath.Join(append([]string{h}, p...)...) } + shell := os.Getenv("SHELL") + var candidates []string + switch { + case strings.Contains(shell, "fish"): + candidates = []string{j(".config", "fish", "config.fish")} + case strings.Contains(shell, "bash"): + candidates = []string{j(".bashrc"), j(".bash_profile"), j(".zshrc")} + default: + candidates = []string{j(".zshrc"), j(".bashrc"), j(".bash_profile")} + } + var out []string + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + out = append(out, c) + } + } + return out +} + +func offerShellIntegration() error { + rcs := shellRCFiles() + if len(rcs) == 0 { + ui.Println(ui.Dim("Tip: add this to your shell rc to auto-cd after creating a workspace:")) + ui.Println(strings.TrimSpace(posixSnippet)) + return nil + } + for _, rc := range rcs { + if data, err := os.ReadFile(rc); err == nil && strings.Contains(string(data), shellMarker) { + ui.Println(ui.Dim("Shell integration already present in " + tilde(rc))) + return nil + } + } + const skip = "Skip" + choice, err := ui.Select("Add the sp() shell function for auto-cd after create?", append(tildeAll(rcs), skip), tilde(rcs[0])) + if err != nil { + return err + } + if choice == skip { + ui.Println(ui.Dim("Skipped. You can add it manually:")) + ui.Println(strings.TrimSpace(posixSnippet)) + return nil + } + rc := rcs[slices.Index(tildeAll(rcs), choice)] + snippet := posixSnippet + if strings.HasSuffix(rc, ".fish") { + snippet = fishSnippet + } + f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fail("%v", err) + } + defer f.Close() + if _, err := f.WriteString(snippet); err != nil { + return fail("%v", err) + } + ui.Success("Added sp() to %s", tilde(rc)) + ui.Hint("Restart your shell or run: source %s", tilde(rc)) + return nil +} + +func configCmd() *cobra.Command { + var edit, reset bool + cmd := &cobra.Command{ + Use: "config", + Short: "Show, edit or reset the config file", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + switch { + case reset: + ok, err := ui.Confirm("Reset config to defaults?", false) + if err != nil || !ok { + return err + } + cfg := config.Default() + cfg.ScanDirs = config.DetectScanDirs() + if _, err := config.Save(cfg); err != nil { + return fail("%v", err) + } + ui.Success("Config reset to defaults.") + return nil + case edit: + if !config.Exists() { + return fail("No config file yet. Run %s first.", ui.Bold("spawnpoint init")) + } + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + for _, e := range []string{"vim", "nano"} { + if _, err := exec.LookPath(e); err == nil { + editor = e + break + } + } + } + parts := strings.Fields(editor) + c := exec.Command(parts[0], append(parts[1:], config.Path())...) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + return c.Run() + } + data, err := os.ReadFile(config.Path()) + if err != nil { + ui.Warning("No config file. Run %s to create one.", ui.Bold("spawnpoint init")) + return nil + } + ui.Println(ui.Bold("Config") + " " + ui.Dim(tilde(config.Path()))) + ui.Blank() + fmt.Print(string(data)) + return nil + }, + } + cmd.Flags().BoolVarP(&edit, "edit", "e", false, "open the config in $EDITOR") + cmd.Flags().BoolVar(&reset, "reset", false, "reset the config to defaults") + return cmd +} diff --git a/internal/cli/spawn.go b/internal/cli/spawn.go new file mode 100644 index 0000000..18a2a68 --- /dev/null +++ b/internal/cli/spawn.go @@ -0,0 +1,196 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/workspace" +) + +// Shared by create and add: plan a worktree per repo, show the plan, then +// build them. + +type planOpts struct { + branch string + base string // explicit --base (or a template's base): applies to every repo + yes bool // take the detected default base without asking + noInput bool + target func(workspace.Repo) string +} + +func prepare(repos []workspace.Repo) { + noun := "repos" + if len(repos) == 1 { + noun = "repo" + } + ui.Spin(fmt.Sprintf("Fetching %d %s…", len(repos), noun), func() { workspace.PrepareAll(repos) }) + ui.Success("Fetched %s", strings.Join(repoNames(repos), ", ")) +} + +func planActions(repos []workspace.Repo, o planOpts) ([]*workspace.Action, error) { + var actions, creates []*workspace.Action + for _, repo := range repos { + target := o.target(repo) + if workspace.Exists(target) { + ui.Warning("Skipping %s: %s already exists.", repo.Base(), tilde(target)) + continue + } + a := &workspace.Action{Repo: repo, Target: target, Branch: o.branch, Type: "add"} + if !workspace.BranchExists(repo.Path, o.branch) { + a.Type = "create" + creates = append(creates, a) + } + actions = append(actions, a) + } + if len(creates) == 0 { + return actions, nil + } + if o.base != "" { + for _, a := range creates { + a.Base = o.base + } + return actions, nil + } + + detected := make([]string, len(creates)) + for i, a := range creates { + detected[i] = workspace.DetectDefaultBranch(a.Repo.Path) + } + + // Interactively, ask once when every new branch would start from the + // same default instead of repeating the question per repo. + if !o.noInput && !o.yes && len(creates) > 1 && allSame(detected) && detected[0] != "" { + const other = "Other (manual input)" + names := make([]string, len(creates)) + for i, a := range creates { + names[i] = a.Repo.Base() + } + title := fmt.Sprintf("'%s' is new in %s. Create it from:", o.branch, strings.Join(names, ", ")) + choice, err := ui.Select(title, []string{detected[0], other}, detected[0]) + if err != nil { + return nil, err + } + if choice == other { + if choice, err = ui.Input("Base branch:", ""); err != nil { + return nil, err + } + } + for _, a := range creates { + a.Base = choice + } + return actions, nil + } + + for i, a := range creates { + base, err := chooseBase(a.Repo, detected[i], o) + if err != nil { + return nil, err + } + a.Base = base + } + return actions, nil +} + +func allSame(s []string) bool { + for _, v := range s[1:] { + if v != s[0] { + return false + } + } + return true +} + +func chooseBase(repo workspace.Repo, detected string, o planOpts) (string, error) { + switch { + case o.noInput: + if detected == "" { + return "", fail("[%s] branch '%s' not found and no default branch detected. Pass --base.", repo.Base(), o.branch) + } + return detected, nil + case o.yes && detected != "": + return detected, nil + case detected != "": + const other = "Other (manual input)" + choice, err := ui.Select(fmt.Sprintf("[%s] Branch '%s' not found. Create from:", repo.Base(), o.branch), []string{detected, other}, detected) + if err != nil || choice != other { + return choice, err + } + return ui.Input(fmt.Sprintf("[%s] Enter base branch:", repo.Base()), "") + default: + return ui.Input(fmt.Sprintf("[%s] Branch '%s' not found. Enter base branch:", repo.Base(), o.branch), "") + } +} + +func printPlan(actions []*workspace.Action) { + width := 0 + for _, a := range actions { + width = max(width, len(a.Repo.Base())) + } + ui.Blank() + ui.Header("Plan") + for _, a := range actions { + name := ui.Bold(fmt.Sprintf("%-*s", width, a.Repo.Base())) + if a.Type == "add" { + ui.Println(" " + name + " " + ui.Good("add worktree") + " for " + ui.Accent(a.Branch)) + } else { + ui.Println(" " + name + " " + ui.Accent2("create branch") + " " + ui.Accent(a.Branch) + " from " + ui.Accent(a.Base)) + } + } + ui.Blank() +} + +// build creates each worktree, then copies config files and installs deps. +func build(actions []*workspace.Action, cfg *config.Config, okStatus string) { + ui.Blank() + for _, a := range actions { + a.Status = "failed" + ui.Println(ui.Bold(a.Repo.Base()) + " " + ui.Dim(tilde(a.Target))) + if err := workspace.AddWorktree(a); err != nil { + ui.Hint("%s", ui.Bad("✗ "+err.Error())) + continue + } + a.Status = okStatus + if a.Type == "add" { + ui.Println(" " + ui.Good("✓") + " worktree on " + ui.Accent(a.Branch)) + } else { + ui.Println(" " + ui.Good("✓") + " worktree on " + ui.Accent(a.Branch) + ui.Dim(" (from "+a.Base+")")) + } + if copied := workspace.CopyEssentialFiles(a.Repo.Path, a.Target, cfg); len(copied) > 0 { + ui.Println(" " + ui.Good("✓") + " copied " + ui.Dim(strings.Join(copied, ", "))) + } + if cfg.AutoInstallDeps { + for _, step := range workspace.SetupDependencies(a.Target) { + ui.Println(" " + ui.Good("✓") + " " + step) + } + } + } +} + +type repoResult struct { + Name string `json:"name"` + Branch string `json:"branch"` + Base *string `json:"base"` + Action string `json:"action"` + Status string `json:"status"` +} + +func results(actions []*workspace.Action) []repoResult { + out := make([]repoResult, len(actions)) + for i, a := range actions { + r := repoResult{Name: a.Repo.Base(), Branch: a.Branch, Action: a.Type, Status: a.Status} + if a.Type == "create" { + base := a.Base + r.Base = &base + } + out[i] = r + } + return out +} + +func writeCDPath(path string) { + _ = os.MkdirAll(config.Dir(), 0o755) + _ = os.WriteFile(config.CDPathFile(), []byte(path), 0o644) +} diff --git a/internal/cli/template.go b/internal/cli/template.go new file mode 100644 index 0000000..a4c241e --- /dev/null +++ b/internal/cli/template.go @@ -0,0 +1,258 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" +) + +func templateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Aliases: []string{"templates"}, + Short: "Manage templates: saved sets of repos you spawn together", + Example: "sp template save billing --repos api,web --base main\nsp create -t billing", + } + cmd.AddCommand(templateListCmd(), templateSaveCmd(), templateShowCmd(), templateDeleteCmd()) + return cmd +} + +func getTemplate(name string) (*config.Template, error) { + templates, err := config.LoadTemplates() + if err != nil { + return nil, fail("%v", err) + } + if t, ok := templates[name]; ok { + return t, nil + } + ui.Error("template '%s' not found. Valid: %s", name, orNone(sortedKeys(templates))) + ui.Hint("Create one with %s.", ui.Bold("spawnpoint template save ")) + return nil, exit(1) +} + +func promptForTemplate(templates map[string]*config.Template) (*config.Template, error) { + const manual = "Pick repos manually" + labels, values := []string{manual}, []string{""} + for _, name := range sortedKeys(templates) { + labels = append(labels, fmt.Sprintf("%s %s", name, ui.Dim("("+templates[name].Detail()+")"))) + values = append(values, name) + } + name, err := ui.SelectLabeled("Start from a template?", labels, values, "") + if err != nil || name == "" { + return nil, err + } + return templates[name], nil +} + +func strOr(p *string, def string) string { + if p == nil { + return def + } + return *p +} + +func templateListCmd() *cobra.Command { + var af agentFlags + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List saved templates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + templates, err := config.LoadTemplates() + if err != nil { + return fail("%v", err) + } + if af.json { + out := []*config.Template{} + for _, name := range sortedKeys(templates) { + out = append(out, templates[name]) + } + return emit(out) + } + if len(templates) == 0 { + ui.Warning("No templates saved yet.") + ui.Hint("Create one with %s, or %s.", ui.Bold("spawnpoint template save "), ui.Bold("spawnpoint create --save-template ")) + return nil + } + var rows [][]string + for _, name := range sortedKeys(templates) { + t := templates[name] + repos := strings.Join(t.Repos, ", ") + if repos == "" { + repos = ui.Dim("none") + } + rows = append(rows, []string{ui.Bold(name), repos, strOr(t.Base, ui.Dim("auto")), strOr(t.Description, "")}) + } + ui.Table([]string{"Template", "Repos", "Base", "Description"}, rows) + return nil + }, + } + af.register(cmd, "") + return cmd +} + +func templateShowCmd() *cobra.Command { + var af agentFlags + cmd := &cobra.Command{ + Use: "show ", + Short: "Show one template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + t, err := getTemplate(args[0]) + if err != nil { + return err + } + if af.json { + return emit(t) + } + ui.Println(ui.Bold(t.Name)) + if t.Description != nil { + ui.Println(" " + *t.Description) + } + ui.Println(" " + ui.Dim("Repos ") + orNone(t.Repos)) + ui.Println(" " + ui.Dim("Base ") + strOr(t.Base, "auto-detected per repo")) + return nil + }, + } + af.register(cmd, "") + return cmd +} + +func templateSaveCmd() *cobra.Command { + var ( + af agentFlags + repos, base, description string + ) + cmd := &cobra.Command{ + Use: "save ", + Short: "Create or update a template (omit --repos to pick interactively)", + Example: "sp template save billing\nspawnpoint template save billing --no-input --repos api,web --base main", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := strings.TrimSpace(args[0]) + if name == "" { + return fail("Template name cannot be empty.") + } + cfg, err := ensureConfig(af) + if err != nil { + return err + } + all, _, err := scanRepos(cfg) + if err != nil { + return err + } + if len(all) == 0 { + return fail("No git repositories found.") + } + if af.noInput { + if err := requireFlag(repos, "--repos"); err != nil { + return err + } + } + + var selected []string + if repos != "" { + picked, err := resolveRepos(parseCSV(repos), all, "") + if err != nil { + return err + } + selected = repoNames(picked) + } else { + templates, err := config.LoadTemplates() + if err != nil { + return fail("%v", err) + } + var pre []string + if existing := templates[name]; existing != nil { + pre = preselectTemplateRepos(existing, all) + } + selected, err = ui.Pick(ui.PickOptions{ + Title: fmt.Sprintf("Select repositories for template '%s'", name), Items: repoNames(all), Selected: pre, Multi: true, + }) + if err != nil { + return err + } + if len(selected) == 0 { + ui.Println("No repositories selected. Nothing saved.") + return nil + } + } + if len(selected) == 0 { + return fail("A template needs at least one repo.") + } + + t, err := config.MergeTemplate(name, selected, base, description) + if err != nil { + return fail("%v", err) + } + path, existed, err := config.UpsertTemplate(t) + if err != nil { + return fail("%v", err) + } + verb := "Saved" + if existed { + verb = "Updated" + } + ui.Success("%s template '%s' %s", verb, name, ui.Dim("("+strings.Join(t.Repos, ", ")+")")) + ui.Hint("%s", tilde(path)) + ui.Hint("Use it with %s", ui.Bold("spawnpoint create --template "+name)) + if af.json { + return emit(t) + } + return nil + }, + } + af.register(cmd, "never prompt; requires --repos") + cmd.Flags().StringVar(&repos, "repos", "", "comma-separated repo names (skips the picker)") + cmd.Flags().StringVar(&base, "base", "", "default base branch for this template") + cmd.Flags().StringVarP(&description, "description", "d", "", "short description shown in listings") + return cmd +} + +func templateDeleteCmd() *cobra.Command { + var af agentFlags + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a template (workspaces are untouched)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + t, err := getTemplate(name) + if err != nil { + return err + } + if !af.noInput { + ok, err := ui.Confirm(fmt.Sprintf("Delete template '%s' (%s)?", name, strings.Join(t.Repos, ", ")), false) + if err != nil { + return err + } + if !ok { + ui.Println(ui.Dim("Aborted.")) + return nil + } + } + templates, err := config.LoadTemplates() + if err != nil { + return fail("%v", err) + } + delete(templates, name) + if _, err := config.SaveTemplates(templates); err != nil { + return fail("%v", err) + } + ui.Success("Deleted template '%s'.", name) + ui.Hint("Existing workspaces are untouched.") + if af.json { + return emit(map[string]string{"deleted": name}) + } + return nil + }, + } + af.register(cmd, "skip the confirmation prompt") + return cmd +} diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..96bef1d --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,66 @@ +package cli + +import ( + "os" + "os/exec" + + "github.com/spf13/cobra" + + "github.com/mihirgupta0900/spawnpoint/internal/ui" + "github.com/mihirgupta0900/spawnpoint/internal/update" +) + +func updateCmd() *cobra.Command { + var check bool + cmd := &cobra.Command{ + Use: "update", + Short: "Update spawnpoint (uses brew, pipx, uv or pip if that's how you installed it)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + current := cmd.Root().Version + m := update.Detect() + + var latest string + ui.Spin("Checking for updates…", func() { + latest, _ = update.Latest() + }) + ui.Println(ui.Dim("installed ") + current + ui.Dim(" via ") + m.Name) + if latest == "" { + return fail("Couldn't reach GitHub to check the latest release.") + } + ui.Println(ui.Dim("latest ") + latest) + + // Dev builds (unparseable versions) always count as outdated. + outdated := update.Newer(latest, current) || !update.Newer(current, "0.0.0") + if !outdated { + ui.Success("Already up to date.") + return nil + } + if check { + ui.Println(ui.Warn("Update available.") + " Run " + ui.Bold("sp update") + ui.Dim(" ("+m.Display()+")")) + return nil + } + + if m.Cmd != nil { + ui.Println(ui.Dim("running ") + ui.Bold(m.Display())) + c := exec.Command(m.Cmd[0], m.Cmd[1:]...) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stderr, os.Stderr + if err := c.Run(); err != nil { + return fail("%s failed: %v", m.Display(), err) + } + ui.Success("Updated to %s", latest) + return nil + } + + var err error + ui.Spin("Downloading "+latest+"…", func() { err = update.SelfUpdate(latest, m.Exe) }) + if err != nil { + return fail("%v", err) + } + ui.Success("Updated %s → %s", current, latest) + return nil + }, + } + cmd.Flags().BoolVar(&check, "check", false, "only report whether an update is available") + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..beb5bb1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,295 @@ +// Package config loads and saves ~/.spawnpoint/config.toml. +// +// The file format is shared with the original Python release, so a config +// written by either version reads the same in the other. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +var ( + DefaultCopyGlobs = []string{".env*"} + DefaultCopyFiles = []string{"AGENT.md", "CLAUDE.md", "GEMINI.md"} + DefaultCopyDirs = []string{".vscode", "docs"} + + commonCodeDirs = []string{"~/code", "~/projects", "~/repos", "~/src", "~/dev"} +) + +type Config struct { + ScanDirs []string + WorktreeDir string + ScanDepth int + CopyGlobs []string + CopyFiles []string + CopyDirs []string + AdditionalWorktreeDirs []string + AutoInstallDeps bool + CheckUpdates bool +} + +func Default() *Config { + return &Config{ + WorktreeDir: filepath.Join(Dir(), "workspaces"), + ScanDepth: 2, + CopyGlobs: clone(DefaultCopyGlobs), + CopyFiles: clone(DefaultCopyFiles), + CopyDirs: clone(DefaultCopyDirs), + AutoInstallDeps: true, + CheckUpdates: true, + } +} + +// WorktreeDirs returns the primary worktree dir followed by any additional +// (legacy) locations, deduplicated. +func (c *Config) WorktreeDirs() []string { + dirs := []string{c.WorktreeDir} + for _, d := range c.AdditionalWorktreeDirs { + if d != c.WorktreeDir { + dirs = append(dirs, d) + } + } + return dirs +} + +// ValidScanDirs returns the configured scan dirs that exist. +func (c *Config) ValidScanDirs() []string { + var out []string + for _, d := range c.ScanDirs { + if isDir(d) { + out = append(out, d) + } + } + return out +} + +func home() string { + if h, err := os.UserHomeDir(); err == nil { + return h + } + return "." +} + +// Dir is ~/.spawnpoint, or $SPAWNPOINT_DIR when set. +func Dir() string { + if d := os.Getenv("SPAWNPOINT_DIR"); d != "" { + return d + } + return filepath.Join(home(), ".spawnpoint") +} +func Path() string { return filepath.Join(Dir(), "config.toml") } +func TemplatesPath() string { return filepath.Join(Dir(), "templates.toml") } +func CDPathFile() string { return filepath.Join(Dir(), ".cd_path") } + +func Exists() bool { + info, err := os.Stat(Path()) + return err == nil && info.Mode().IsRegular() +} + +// ExpandPath expands ~ and resolves the path to an absolute, symlink-free form +// where possible. +func ExpandPath(p string) string { + if p == "~" { + p = home() + } else if strings.HasPrefix(p, "~/") { + p = filepath.Join(home(), p[2:]) + } + abs, err := filepath.Abs(p) + if err != nil { + return p + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + +// DetectScanDirs returns common code directories that exist on this machine. +func DetectScanDirs() []string { + var found []string + for _, d := range commonCodeDirs { + p := filepath.Join(home(), strings.TrimPrefix(d, "~/")) + if isDir(p) { + found = append(found, p) + } + } + return found +} + +type fileFormat struct { + ScanDirs *[]string `toml:"scan_dirs"` + WorktreeDir *string `toml:"worktree_dir"` + ScanDepth *int `toml:"scan_depth"` + CopyGlobs *[]string `toml:"copy_patterns_globs"` + CopyFiles *[]string `toml:"copy_patterns_files"` + CopyDirs *[]string `toml:"copy_patterns_dirs"` + AdditionalWorktreeDirs *[]string `toml:"additional_worktree_dirs"` + AutoInstallDeps *bool `toml:"auto_install_deps"` + CheckUpdates *bool `toml:"check_updates"` +} + +// Load reads the config file, falling back to defaults for missing keys (or +// the whole file). +func Load() (*Config, error) { + cfg := Default() + data, err := os.ReadFile(Path()) + if os.IsNotExist(err) { + return cfg, nil + } + if err != nil { + return nil, err + } + + var f fileFormat + if _, err := toml.Decode(string(data), &f); err != nil { + return nil, fmt.Errorf("%s is not valid TOML: %w", Path(), err) + } + if f.ScanDirs != nil { + cfg.ScanDirs = expandAll(*f.ScanDirs) + } + if f.WorktreeDir != nil { + cfg.WorktreeDir = ExpandPath(*f.WorktreeDir) + } + if f.ScanDepth != nil { + cfg.ScanDepth = *f.ScanDepth + } + if f.CopyGlobs != nil { + cfg.CopyGlobs = *f.CopyGlobs + } + if f.CopyFiles != nil { + cfg.CopyFiles = *f.CopyFiles + } + if f.CopyDirs != nil { + cfg.CopyDirs = *f.CopyDirs + } + if f.AdditionalWorktreeDirs != nil { + cfg.AdditionalWorktreeDirs = expandAll(*f.AdditionalWorktreeDirs) + } + if f.AutoInstallDeps != nil { + cfg.AutoInstallDeps = *f.AutoInstallDeps + } + if f.CheckUpdates != nil { + cfg.CheckUpdates = *f.CheckUpdates + } + return cfg, nil +} + +// Save writes the config with comments, using ~ for paths under $HOME. +func Save(cfg *Config) (string, error) { + if err := os.MkdirAll(Dir(), 0o755); err != nil { + return "", err + } + tilde := func(ps []string) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = tildePath(p) + } + return out + } + lines := []string{ + "# Spawnpoint configuration", + "# https://github.com/mihirgupta0900/spawnpoint", + "", + "# Directories to scan for git repos", + "scan_dirs = " + tomlList(tilde(cfg.ScanDirs)), + "", + "# Where workspaces are created", + "worktree_dir = " + tomlString(tildePath(cfg.WorktreeDir)), + "", + "# Additional directories to scan during cleanup (for worktrees created at previous locations)", + "additional_worktree_dirs = " + tomlList(tilde(cfg.AdditionalWorktreeDirs)), + "", + "# How deep to scan for repos (1-4)", + fmt.Sprintf("scan_depth = %d", cfg.ScanDepth), + "", + "# Glob patterns for files to copy into new worktrees", + "copy_patterns_globs = " + tomlList(cfg.CopyGlobs), + "", + "# Specific files to copy", + "copy_patterns_files = " + tomlList(cfg.CopyFiles), + "", + "# Directories to copy", + "copy_patterns_dirs = " + tomlList(cfg.CopyDirs), + "", + "# Auto-install dependencies after worktree creation", + fmt.Sprintf("auto_install_deps = %t", cfg.AutoInstallDeps), + "", + "# Check for new versions on startup", + fmt.Sprintf("check_updates = %t", cfg.CheckUpdates), + "", + } + return Path(), os.WriteFile(Path(), []byte(strings.Join(lines, "\n")), 0o644) +} + +func tildePath(p string) string { + h := home() + if rel, err := filepath.Rel(h, p); err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + return "~/" + rel + } + return p +} + +// tomlString prefers a literal ('...') string, as the Python release wrote, +// and falls back to an escaped basic string when the value needs it. +func tomlString(s string) string { + if !strings.ContainsAny(s, "'\n\r\t") { + return "'" + s + "'" + } + return BasicString(s) +} + +func tomlList(items []string) string { + quoted := make([]string, len(items)) + for i, s := range items { + quoted[i] = tomlString(s) + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +// BasicString quotes s as a TOML basic ("...") string. +func BasicString(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 || r == 0x7f { + fmt.Fprintf(&b, `\u%04X`, r) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + +func expandAll(ps []string) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = ExpandPath(p) + } + return out +} + +func clone(s []string) []string { return append([]string(nil), s...) } + +func isDir(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7f80537 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + code := filepath.Join(home, "code") + os.MkdirAll(code, 0o755) + + cfg := Default() + cfg.ScanDirs = []string{code} + cfg.ScanDepth = 3 + cfg.AutoInstallDeps = false + if _, err := Save(cfg); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(Path()) + // Same layout the Python release wrote: ~ paths, single-quoted strings. + for _, want := range []string{"scan_dirs = ['~/code']", "worktree_dir = '~/.spawnpoint/workspaces'", "copy_patterns_globs = ['.env*']", "auto_install_deps = false"} { + if !contains(string(data), want) { + t.Errorf("config missing %q:\n%s", want, data) + } + } + + got, err := Load() + if err != nil { + t.Fatal(err) + } + resolved, _ := filepath.EvalSymlinks(code) + if !slices.Equal(got.ScanDirs, []string{resolved}) || got.ScanDepth != 3 || got.AutoInstallDeps || !got.CheckUpdates { + t.Fatalf("loaded = %+v", got) + } +} + +func TestLoadPartialConfigKeepsDefaults(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(Dir(), 0o755) + os.WriteFile(Path(), []byte("scan_depth = 1\n"), 0o644) + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.ScanDepth != 1 || !got.AutoInstallDeps || !slices.Equal(got.CopyFiles, DefaultCopyFiles) { + t.Fatalf("loaded = %+v", got) + } +} + +func TestTemplateEscapingRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + tricky := "we\"ird\\name.with dots\n\x01" + base := "staging" + if _, err := SaveTemplates(map[string]*Template{tricky: {Name: tricky, Repos: []string{"a/b", "c"}, Base: &base}}); err != nil { + t.Fatal(err) + } + got, err := LoadTemplates() + if err != nil { + t.Fatal(err) + } + tmpl := got[tricky] + if tmpl == nil || !slices.Equal(tmpl.Repos, []string{"a/b", "c"}) || *tmpl.Base != "staging" || tmpl.Description != nil { + t.Fatalf("templates = %+v", got) + } +} + +func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0) } + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/config/templates.go b/internal/config/templates.go new file mode 100644 index 0000000..7a152a7 --- /dev/null +++ b/internal/config/templates.go @@ -0,0 +1,146 @@ +package config + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// Template is a named set of repos to spawn together, plus an optional +// default base branch. +// +// Templates live in their own file (~/.spawnpoint/templates.toml) because the +// CLI writes them, while config.toml stays a hand-edited settings file that +// `init` and `config --reset` regenerate. +type Template struct { + Name string `json:"name"` + Repos []string `json:"repos"` + Base *string `json:"base"` + Description *string `json:"description"` +} + +// LoadTemplates returns templates keyed by name (empty if none are saved). +func LoadTemplates() (map[string]*Template, error) { + out := map[string]*Template{} + data, err := os.ReadFile(TemplatesPath()) + if os.IsNotExist(err) { + return out, nil + } + if err != nil { + return nil, err + } + + var raw struct { + Templates map[string]map[string]any `toml:"templates"` + } + if _, err := toml.Decode(string(data), &raw); err != nil { + return nil, fmt.Errorf("%s is not valid TOML: %w\nFix the file by hand, or delete it to start over", TemplatesPath(), err) + } + for name, body := range raw.Templates { + t := &Template{Name: name, Repos: []string{}} + if repos, ok := body["repos"].([]any); ok { + for _, r := range repos { + t.Repos = append(t.Repos, fmt.Sprint(r)) + } + } + if s, ok := body["base"].(string); ok && s != "" { + t.Base = &s + } + if s, ok := body["description"].(string); ok && s != "" { + t.Description = &s + } + out[name] = t + } + return out, nil +} + +// SaveTemplates writes all templates, sorted by name. +func SaveTemplates(templates map[string]*Template) (string, error) { + if err := os.MkdirAll(Dir(), 0o755); err != nil { + return "", err + } + lines := []string{ + "# Spawnpoint workspace templates", + "# Managed by `spawnpoint template save` and `spawnpoint template delete`.", + "# Repo names must match the names shown by `spawnpoint repos`.", + "", + } + for _, name := range SortedNames(templates) { + t := templates[name] + lines = append(lines, "[templates."+BasicString(name)+"]") + if t.Description != nil { + lines = append(lines, "description = "+BasicString(*t.Description)) + } + quoted := make([]string, len(t.Repos)) + for i, r := range t.Repos { + quoted[i] = BasicString(r) + } + lines = append(lines, "repos = ["+strings.Join(quoted, ", ")+"]") + if t.Base != nil { + lines = append(lines, "base = "+BasicString(*t.Base)) + } + lines = append(lines, "") + } + return TemplatesPath(), os.WriteFile(TemplatesPath(), []byte(strings.Join(lines, "\n")), 0o644) +} + +// UpsertTemplate saves t and reports whether it replaced an existing template. +func UpsertTemplate(t *Template) (path string, existed bool, err error) { + templates, err := LoadTemplates() + if err != nil { + return "", false, err + } + _, existed = templates[t.Name] + templates[t.Name] = t + path, err = SaveTemplates(templates) + return path, existed, err +} + +// MergeTemplate builds a template, keeping the saved base/description when +// the caller doesn't override them. +func MergeTemplate(name string, repos []string, base, description string) (*Template, error) { + templates, err := LoadTemplates() + if err != nil { + return nil, err + } + t := &Template{Name: name, Repos: repos} + existing := templates[name] + switch { + case base != "": + t.Base = &base + case existing != nil: + t.Base = existing.Base + } + switch { + case description != "": + t.Description = &description + case existing != nil: + t.Description = existing.Description + } + return t, nil +} + +// Detail is a one-line summary for pickers and tables. +func (t *Template) Detail() string { + const max = 60 + detail := strings.Join(t.Repos, ", ") + if t.Description != nil { + detail = *t.Description + } + if len(detail) > max { + detail = detail[:max-3] + "..." + } + return detail +} + +func SortedNames[V any](m map[string]V) []string { + names := make([]string, 0, len(m)) + for n := range m { + names = append(names, n) + } + sort.Strings(names) + return names +} diff --git a/internal/ui/inline.go b/internal/ui/inline.go new file mode 100644 index 0000000..3f6700b --- /dev/null +++ b/internal/ui/inline.go @@ -0,0 +1,49 @@ +package ui + +import ( + "fmt" + "os" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// inline wraps a model and remembers how tall its last frame was. +// +// Bubble Tea v2's inline renderer leaves a finished program's last frame on +// screen, which would stack stale pickers and prompts above our one-line +// summaries. runInline erases the frame after the program exits instead. +type inline struct { + inner tea.Model + height int +} + +func (m *inline) Init() tea.Cmd { return m.inner.Init() } + +func (m *inline) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.inner, cmd = m.inner.Update(msg) + return m, cmd +} + +func (m *inline) View() tea.View { + v := m.inner.View() + // Drop only the final newline: padded blank lines before it are part of + // the frame. + if c := strings.TrimSuffix(v.Content, "\n"); c != "" { + m.height = strings.Count(c, "\n") + 1 + v.SetContent(c) + } + return v +} + +// runInline runs model on stderr and clears its output when it finishes. +func runInline(model tea.Model) (tea.Model, error) { + w := &inline{inner: model} + _, err := tea.NewProgram(w, tea.WithOutput(os.Stderr)).Run() + if w.height > 1 { + fmt.Fprintf(os.Stderr, "\x1b[%dA", w.height-1) + } + fmt.Fprint(os.Stderr, "\r\x1b[J") + return w.inner, err +} diff --git a/internal/ui/picker.go b/internal/ui/picker.go new file mode 100644 index 0000000..dcfc352 --- /dev/null +++ b/internal/ui/picker.go @@ -0,0 +1,315 @@ +package ui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +// PickOptions configures Pick. +type PickOptions struct { + Title string + Items []string // display labels, returned as-is + Selected []string // labels toggled on at start (multi only) + Multi bool +} + +// Pick shows a type-to-search fuzzy picker and returns the chosen labels in +// list order. Typing filters immediately; tab/space toggles in multi mode and +// clears the search so the next repo can be found; enter confirms (picking +// the highlighted row if nothing is toggled). +func Pick(opts PickOptions) ([]string, error) { + pm := newPicker(opts) + if _, err := runInline(pm); err != nil { + return nil, err + } + if pm.aborted { + return nil, ErrAborted + } + result := pm.result() + answered(opts.Title, strings.Join(result, ", ")) + return result, nil +} + +const pickerHeight = 10 + +type match struct { + idx int + positions []int +} + +type picker struct { + opts PickOptions + query string + matches []match + cursor int + offset int + selected map[int]bool + done bool + aborted bool +} + +func newPicker(opts PickOptions) *picker { + m := &picker{opts: opts, selected: map[int]bool{}} + pre := map[string]bool{} + for _, s := range opts.Selected { + pre[s] = true + } + for i, it := range opts.Items { + if pre[it] { + m.selected[i] = true + } + } + m.filter() + return m +} + +func (m *picker) Init() tea.Cmd { return nil } + +func (m *picker) filter() { + m.matches = m.matches[:0] + if m.query == "" { + for i := range m.opts.Items { + m.matches = append(m.matches, match{idx: i}) + } + } else { + for _, r := range fuzzy.Find(m.query, m.opts.Items) { + m.matches = append(m.matches, match{idx: r.Index, positions: r.MatchedIndexes}) + } + } + m.cursor = min(m.cursor, max(len(m.matches)-1, 0)) + m.scroll() +} + +func (m *picker) scroll() { + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+pickerHeight { + m.offset = m.cursor - pickerHeight + 1 + } +} + +func (m *picker) move(d int) { + if len(m.matches) == 0 { + return + } + m.cursor = (m.cursor + d + len(m.matches)) % len(m.matches) + m.scroll() +} + +func (m *picker) toggle() { + if len(m.matches) == 0 { + return + } + idx := m.matches[m.cursor].idx + m.selected[idx] = !m.selected[idx] + if m.query != "" { + // Clear the search and keep the cursor on the row just toggled. + m.query = "" + m.filter() + m.cursor = idx + m.scroll() + } +} + +func (m *picker) result() []string { + var out []string + if m.opts.Multi { + for i, it := range m.opts.Items { + if m.selected[i] { + out = append(out, it) + } + } + if len(out) > 0 { + return out + } + } + if len(m.matches) > 0 { + return []string{m.opts.Items[m.matches[m.cursor].idx]} + } + return nil +} + +func (m *picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + switch key.String() { + case "ctrl+c", "esc": + m.aborted = true + return m, tea.Quit + case "enter": + if len(m.result()) == 0 { + return m, nil + } + m.done = true + return m, tea.Quit + case "up", "ctrl+p", "ctrl+k": + m.move(-1) + case "down", "ctrl+n", "ctrl+j": + m.move(1) + case "pgup": + m.move(-pickerHeight) + case "pgdown": + m.move(pickerHeight) + case "tab", "space": + if m.opts.Multi { + m.toggle() + if key.String() == "tab" { + m.move(1) + } + } else if key.String() == "space" { + m.query += " " + m.filter() + } + case "ctrl+a": + if m.opts.Multi { + all := true + for _, mt := range m.matches { + all = all && m.selected[mt.idx] + } + for _, mt := range m.matches { + m.selected[mt.idx] = !all + } + } + case "backspace": + if r := []rune(m.query); len(r) > 0 { + m.query = string(r[:len(r)-1]) + m.filter() + } + case "ctrl+u", "ctrl+w": + m.query = "" + m.filter() + default: + if key.Text != "" { + m.query += key.Text + m.cursor = 0 + m.filter() + } + } + return m, nil +} + +var ( + pTitle = lipgloss.NewStyle().Foreground(Blue).Bold(true) + pPrompt = lipgloss.NewStyle().Foreground(Purple) + pCursor = lipgloss.NewStyle().Foreground(Purple).Bold(true) + pRowOn = lipgloss.NewStyle().Foreground(lipgloss.Color("#E6EDF3")) + pRow = lipgloss.NewStyle().Foreground(Gray) + pMatch = lipgloss.NewStyle().Foreground(Blue).Bold(true) + pChecked = lipgloss.NewStyle().Foreground(Green) + pDim = lipgloss.NewStyle().Foreground(Faint) + pSelected = lipgloss.NewStyle().Foreground(Green) +) + +func (m *picker) View() tea.View { + if m.done || m.aborted { + return tea.NewView("") + } + var b strings.Builder + + help := "type to search · ↑↓ move · enter confirm" + if m.opts.Multi { + help = "type to search · tab/space toggle · ctrl+a all · enter confirm" + } + b.WriteString(pTitle.Render(m.opts.Title) + " " + pDim.Render(help) + "\n") + + count := pDim.Render(fmt.Sprintf("%d/%d", len(m.matches), len(m.opts.Items))) + line := pPrompt.Render("› ") + m.query + pCursor.Render("▏") + " " + count + if m.opts.Multi { + var names []string + for i, it := range m.opts.Items { + if m.selected[i] { + names = append(names, it) + } + } + if len(names) > 0 { + line += pDim.Render(" · ") + pSelected.Render(fmt.Sprintf("selected (%d): ", len(names))) + strings.Join(names, ", ") + } + } + b.WriteString(line + "\n") + + // The frame keeps a constant height (rows are padded) so filtering never + // shrinks it: Bubble Tea's inline renderer leaves stale lines behind when + // a frame gets shorter. + rows := min(len(m.opts.Items), pickerHeight) + end := min(m.offset+pickerHeight, len(m.matches)) + drawn := 0 + for i := m.offset; i < end; i++ { + drawn++ + mt := m.matches[i] + label := m.opts.Items[mt.idx] + here := i == m.cursor + + cur := " " + if here { + cur = pCursor.Render("❯ ") + } + box := "" + if m.opts.Multi { + if m.selected[mt.idx] { + box = pChecked.Render("◉ ") + } else { + box = pDim.Render("○ ") + } + } + base := pRow + if here || m.selected[mt.idx] { + base = pRowOn + } + b.WriteString(cur + box + highlight(label, mt.positions, base) + "\n") + } + if len(m.matches) == 0 { + b.WriteString(pDim.Render(" no matches") + "\n") + drawn++ + } + for ; drawn < rows; drawn++ { + b.WriteString("\n") + } + if len(m.opts.Items) > pickerHeight { + if hidden := len(m.matches) - end; hidden > 0 { + b.WriteString(pDim.Render(fmt.Sprintf(" … %d more", hidden))) + } + b.WriteString("\n") + } + return tea.NewView(b.String()) +} + +func highlight(s string, positions []int, base lipgloss.Style) string { + if len(positions) == 0 { + return base.Render(s) + } + hit := map[int]bool{} + for _, p := range positions { + hit[p] = true + } + // Render runs of matched / unmatched runes (positions are byte offsets). + var b strings.Builder + var run strings.Builder + runHit := false + flush := func() { + if run.Len() == 0 { + return + } + if runHit { + b.WriteString(pMatch.Render(run.String())) + } else { + b.WriteString(base.Render(run.String())) + } + run.Reset() + } + for i, r := range s { + if hit[i] != runHit { + flush() + runHit = hit[i] + } + run.WriteRune(r) + } + flush() + return b.String() +} diff --git a/internal/ui/picker_test.go b/internal/ui/picker_test.go new file mode 100644 index 0000000..26e6e28 --- /dev/null +++ b/internal/ui/picker_test.go @@ -0,0 +1,106 @@ +package ui + +import ( + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func press(m *picker, keys ...string) { + for _, k := range keys { + var msg tea.KeyPressMsg + switch k { + case "tab": + msg = tea.KeyPressMsg{Code: tea.KeyTab} + case "space": + msg = tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + case "enter": + msg = tea.KeyPressMsg{Code: tea.KeyEnter} + case "down": + msg = tea.KeyPressMsg{Code: tea.KeyDown} + case "backspace": + msg = tea.KeyPressMsg{Code: tea.KeyBackspace} + case "esc": + msg = tea.KeyPressMsg{Code: tea.KeyEscape} + default: // literal text + for _, r := range k { + m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + continue + } + m.Update(msg) + } +} + +var repos = []string{"api", "billing-docs", "infra", "web", "worker"} + +func TestPickerFuzzyFilter(t *testing.T) { + m := newPicker(PickOptions{Items: repos, Multi: true}) + press(m, "wr") + var got []string + for _, mt := range m.matches { + got = append(got, repos[mt.idx]) + } + if !slices.Equal(got, []string{"worker"}) { + t.Fatalf("matches = %v", got) + } +} + +func TestPickerToggleClearsSearch(t *testing.T) { + m := newPicker(PickOptions{Items: repos, Multi: true}) + press(m, "web", "space") + if m.query != "" || len(m.matches) != len(repos) { + t.Fatalf("search not cleared: query=%q matches=%d", m.query, len(m.matches)) + } + if repos[m.matches[m.cursor].idx] != "web" { + t.Fatalf("cursor on %s, want web", repos[m.matches[m.cursor].idx]) + } + press(m, "api", "tab", "enter") + if !m.done || !slices.Equal(m.result(), []string{"api", "web"}) { + t.Fatalf("result = %v (done=%v)", m.result(), m.done) + } + if v := m.View().Content; v != "" { + t.Fatalf("view not cleared after confirm: %q", v) + } +} + +func TestPickerEnterPicksHighlightedWhenNothingToggled(t *testing.T) { + m := newPicker(PickOptions{Items: repos, Multi: true}) + press(m, "down", "down", "enter") + if !slices.Equal(m.result(), []string{"infra"}) { + t.Fatalf("result = %v", m.result()) + } +} + +func TestPickerPreselectedAndSelectedLine(t *testing.T) { + m := newPicker(PickOptions{Items: repos, Selected: []string{"worker", "api"}, Multi: true}) + if !slices.Equal(m.result(), []string{"api", "worker"}) { + t.Fatalf("result = %v", m.result()) + } + if !strings.Contains(ansi.Strip(m.View().Content), "selected (2): api, worker") { + t.Fatalf("view:\n%s", m.View().Content) + } +} + +func TestPickerSingleSelectTypesSpaces(t *testing.T) { + m := newPicker(PickOptions{Items: []string{"feat-a (1 repo)", "feat-b (2 repos)"}}) + press(m, "b", "space", "2", "enter") + if !slices.Equal(m.result(), []string{"feat-b (2 repos)"}) { + t.Fatalf("result = %v", m.result()) + } +} + +func TestPickerNoMatchesDoesNotConfirm(t *testing.T) { + m := newPicker(PickOptions{Items: repos, Multi: true}) + press(m, "zzz", "enter") + if m.done { + t.Fatal("confirmed with nothing to pick") + } + press(m, "backspace", "backspace", "backspace", "esc") + if !m.aborted { + t.Fatal("esc should abort") + } +} diff --git a/internal/ui/progress.go b/internal/ui/progress.go new file mode 100644 index 0000000..d3b96e4 --- /dev/null +++ b/internal/ui/progress.go @@ -0,0 +1,94 @@ +package ui + +import ( + "errors" + "os" + "sync/atomic" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/charmbracelet/x/term" +) + +// Interactive reports whether stderr is a terminal (so spinners and pickers +// make sense). +func Interactive() bool { return term.IsTerminal(os.Stderr.Fd()) } + +type spinDone struct{} + +type spinModel struct { + spin spinner.Model + title string + run func() + done bool +} + +func (m *spinModel) Init() tea.Cmd { + return tea.Batch(m.spin.Tick, func() tea.Msg { m.run(); return spinDone{} }) +} + +func (m *spinModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinDone: + m.done = true + return m, tea.Quit + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + return m, tea.Interrupt + } + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd +} + +// View clears itself once the action finishes, so the next line of output +// starts clean. +func (m *spinModel) View() tea.View { + if m.done { + return tea.NewView("") + } + return tea.NewView(m.spin.View() + " " + dimStyle.Render(m.title)) +} + +// Spin runs action behind a spinner titled title. Without a terminal it just +// runs the action. +func Spin(title string, action func()) { + if !Interactive() { + action() + return + } + var started atomic.Bool + m := &spinModel{ + spin: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(lipgloss.NewStyle().Foreground(Purple))), + title: title, + run: func() { started.Store(true); action() }, + } + _, err := runInline(m) + switch { + case errors.Is(err, tea.ErrInterrupted): + Println(Dim("Interrupted.")) + os.Exit(130) + case err != nil && !started.Load(): + action() // the terminal couldn't host the spinner; do the work anyway + } +} + +// Table renders a rounded, dim-bordered table with a bold header row. +func Table(headers []string, rows [][]string) { + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(Faint)). + Headers(headers...). + Rows(rows...). + StyleFunc(func(row, _ int) lipgloss.Style { + s := lipgloss.NewStyle().Padding(0, 1) + if row == table.HeaderRow { + return s.Bold(true).Foreground(Blue) + } + return s + }) + Println(t.Render()) +} diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go new file mode 100644 index 0000000..7878cbf --- /dev/null +++ b/internal/ui/prompt.go @@ -0,0 +1,133 @@ +package ui + +import ( + "errors" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +// ErrAborted is returned when the user cancels a prompt (ctrl+c / esc). +var ErrAborted = errors.New("aborted") + +func theme() huh.Theme { + return huh.ThemeFunc(func(isDark bool) *huh.Styles { + t := huh.ThemeCharm(isDark) + t.Focused.Title = t.Focused.Title.Foreground(Blue) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(Purple) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(Purple) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(Purple) + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(Blue) + t.Focused.FocusedButton = t.Focused.FocusedButton.Background(Blue).Foreground(lipgloss.Color("#0A0D14")) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(Green) + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + return t + }) +} + +// formModel hosts a huh form in our own program (see runInline) so its +// frame can be cleared once answered. +type formModel struct{ f *huh.Form } + +func (m formModel) Init() tea.Cmd { return m.f.Init() } + +func (m formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + f, cmd := m.f.Update(msg) + m.f = f.(*huh.Form) + return m, cmd +} + +func (m formModel) View() tea.View { return tea.NewView(m.f.View()) } + +func run(field huh.Field) error { + f := huh.NewForm(huh.NewGroup(field)).WithTheme(theme()).WithShowHelp(false) + f.SubmitCmd = tea.Quit + f.CancelCmd = tea.Interrupt + _, err := runInline(formModel{f}) + if errors.Is(err, tea.ErrInterrupted) || f.State == huh.StateAborted { + return ErrAborted + } + return err +} + +// answered leaves a one-line record of a finished prompt, since huh clears +// its view on completion. +func answered(title, answer string) { + Println(Good("✓") + " " + Bold(strings.TrimSuffix(title, ":")) + " " + Accent(answer)) +} + +// Confirm asks a yes/no question. +func Confirm(title string, def bool) (bool, error) { + v := def + if err := run(huh.NewConfirm().Title(title).Affirmative("Yes").Negative("No").Value(&v)); err != nil { + return false, err + } + answer := "No" + if v { + answer = "Yes" + } + answered(title, answer) + return v, nil +} + +// Input asks for a line of text. +func Input(title, def string) (string, error) { + v := def + if err := run(huh.NewInput().Title(title).Value(&v)); err != nil { + return "", err + } + v = strings.TrimSpace(v) + answered(title, v) + return v, nil +} + +// Select asks the user to choose one of options. +func Select(title string, options []string, def string) (string, error) { + v := def + opts := make([]huh.Option[string], len(options)) + for i, o := range options { + opts[i] = huh.NewOption(o, o) + } + if err := run(huh.NewSelect[string]().Title(title).Options(opts...).Value(&v)); err != nil { + return "", err + } + answered(title, v) + return v, nil +} + +// SelectLabeled is Select with display labels distinct from the returned +// values. +func SelectLabeled(title string, labels, values []string, def string) (string, error) { + v := def + opts := make([]huh.Option[string], len(labels)) + for i := range labels { + opts[i] = huh.NewOption(labels[i], values[i]) + } + if err := run(huh.NewSelect[string]().Title(title).Options(opts...).Value(&v)); err != nil { + return "", err + } + for i := range values { + if values[i] == v { + answered(title, labels[i]) + } + } + return v, nil +} + +// Checklist is a plain multi-select (no fuzzy search) with every option +// pre-selected. +func Checklist(title string, labels, values []string) ([]string, error) { + v := append([]string(nil), values...) + opts := make([]huh.Option[string], len(labels)) + for i := range labels { + opts[i] = huh.NewOption(labels[i], values[i]).Selected(true) + } + if err := run(huh.NewMultiSelect[string]().Title(title).Options(opts...).Value(&v)); err != nil { + return nil, err + } + answered(title, strings.Join(v, ", ")) + return v, nil +} diff --git a/internal/ui/style.go b/internal/ui/style.go new file mode 100644 index 0000000..b7f4327 --- /dev/null +++ b/internal/ui/style.go @@ -0,0 +1,98 @@ +// Package ui holds everything the user sees: styled status output, prompts, +// the fuzzy picker, spinners and tables. +// +// All human-readable output goes to stderr so stdout stays reserved for +// machine output (--json, or the bare workspace path in --no-input mode). +package ui + +import ( + "fmt" + "io" + "os" + + "charm.land/lipgloss/v2" +) + +// Out is where human-readable output goes. +var Out io.Writer = os.Stderr + +var ( + Blue = lipgloss.Color("#58A6FF") + Purple = lipgloss.Color("#BC8CFF") + Green = lipgloss.Color("#3FB950") + Yellow = lipgloss.Color("#E3B341") + Red = lipgloss.Color("#F85149") + Orange = lipgloss.Color("#F0883E") + Gray = lipgloss.Color("#8B95A5") + Faint = lipgloss.Color("#5A6474") +) + +var ( + boldStyle = lipgloss.NewStyle().Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(Gray) + faintStyle = lipgloss.NewStyle().Foreground(Faint) + blueStyle = lipgloss.NewStyle().Foreground(Blue) + purpleStyle = lipgloss.NewStyle().Foreground(Purple) + greenStyle = lipgloss.NewStyle().Foreground(Green) + yellowStyle = lipgloss.NewStyle().Foreground(Yellow) + redStyle = lipgloss.NewStyle().Foreground(Red) + headerStyle = lipgloss.NewStyle().Foreground(Blue).Bold(true) +) + +func Bold(s string) string { return boldStyle.Render(s) } +func Dim(s string) string { return dimStyle.Render(s) } +func Faded(s string) string { return faintStyle.Render(s) } +func Accent(s string) string { return blueStyle.Render(s) } +func Accent2(s string) string { + return purpleStyle.Render(s) +} +func Good(s string) string { return greenStyle.Render(s) } +func Warn(s string) string { return yellowStyle.Render(s) } +func Bad(s string) string { return redStyle.Render(s) } + +// Println writes a line of (possibly styled) output, downsampling colors to +// what the terminal supports and stripping them when it isn't a TTY. +func Println(a ...any) { lipgloss.Fprintln(Out, a...) } + +func Printf(format string, a ...any) { lipgloss.Fprint(Out, fmt.Sprintf(format, a...)) } + +// Blank prints an empty line. +func Blank() { lipgloss.Fprintln(Out) } + +// Header is a section heading, e.g. "Preparing repositories". +func Header(s string) { Println(headerStyle.Render(s)) } + +// Success is a green check line. +func Success(format string, a ...any) { + Println(greenStyle.Render("✓") + " " + fmt.Sprintf(format, a...)) +} + +// Warning is a yellow line. +func Warning(format string, a ...any) { + Println(yellowStyle.Render("! " + fmt.Sprintf(format, a...))) +} + +// Error prints "✗ Error: ..." in red. +func Error(format string, a ...any) { + Println(redStyle.Bold(true).Render("✗ Error:") + " " + fmt.Sprintf(format, a...)) +} + +// Hint is an indented dim line. +func Hint(format string, a ...any) { + Println(dimStyle.Render(" " + fmt.Sprintf(format, a...))) +} + +// Item is an indented line with a dim bullet. +func Item(format string, a ...any) { + Println(" " + faintStyle.Render("•") + " " + fmt.Sprintf(format, a...)) +} + +// Done prints the final "Done!" banner. +func Done(extra string) { + Blank() + line := greenStyle.Bold(true).Render("✓ Done!") + if extra != "" { + line += " " + extra + } + Println(line) +} diff --git a/internal/update/check.go b/internal/update/check.go new file mode 100644 index 0000000..56be2cf --- /dev/null +++ b/internal/update/check.go @@ -0,0 +1,160 @@ +// Package update checks for new releases and upgrades spawnpoint in place, +// using whichever tool installed it (Homebrew, pipx, uv, pip, go install) or +// replacing the binary directly for curl/manual installs. +package update + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" +) + +const ( + Repo = "mihirgupta0900/spawnpoint" + cacheTTL = 24 * time.Hour +) + +// releasesURL is the base for release lookups and downloads; overridable for +// tests via SPAWNPOINT_RELEASES_URL. +func releasesURL() string { + if u := os.Getenv("SPAWNPOINT_RELEASES_URL"); u != "" { + return strings.TrimRight(u, "/") + } + return "https://github.com/" + Repo + "/releases" +} + +var client = &http.Client{Timeout: 3 * time.Second} + +// Latest returns the newest release version (without the leading "v"). +// It follows the /releases/latest redirect instead of calling the GitHub API, +// so it isn't subject to API rate limits. +func Latest() (string, error) { + noRedirect := *client + noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + resp, err := noRedirect.Head(releasesURL() + "/latest") + if err != nil { + return "", err + } + resp.Body.Close() + loc := resp.Header.Get("Location") + i := strings.LastIndex(loc, "/tag/") + if i < 0 { + return "", fmt.Errorf("unexpected response from %s (status %d)", releasesURL(), resp.StatusCode) + } + return strings.TrimPrefix(loc[i+len("/tag/"):], "v"), nil +} + +// The cache format matches the Python release's .version_cache.json. +type cache struct { + Latest string `json:"latest_version"` + CheckedAt float64 `json:"checked_at"` +} + +func cachePath() string { return filepath.Join(config.Dir(), ".version_cache.json") } + +func readCache() string { + data, err := os.ReadFile(cachePath()) + if err != nil { + return "" + } + var c cache + if json.Unmarshal(data, &c) != nil { + return "" + } + if time.Since(time.Unix(int64(c.CheckedAt), 0)) > cacheTTL { + return "" + } + return c.Latest +} + +func writeCache(latest string) { + data, _ := json.Marshal(cache{latest, float64(time.Now().Unix())}) + _ = os.MkdirAll(config.Dir(), 0o755) + _ = os.WriteFile(cachePath(), data, 0o644) +} + +// Checker looks up the latest version in the background so it never slows +// a command down. +type Checker struct { + current string + done chan struct{} + latest string +} + +func NewChecker(current string) *Checker { return &Checker{current: current} } + +func (c *Checker) Start() { + c.done = make(chan struct{}) + go func() { + defer close(c.done) + if v := readCache(); v != "" { + c.latest = v + return + } + if v, err := Latest(); err == nil && v != "" { + c.latest = v + writeCache(v) + } + }() +} + +// Notice returns an "update available" line, or "" (also if the check hasn't +// finished within a moment). +func (c *Checker) Notice() string { + if c.done == nil { + return "" + } + select { + case <-c.done: + case <-time.After(300 * time.Millisecond): + return "" + } + if !Newer(c.latest, c.current) { + return "" + } + return ui.Dim(fmt.Sprintf("Update available: %s → %s (run ", c.current, c.latest)) + ui.Bold("sp update") + ui.Dim(" to upgrade)") +} + +// Newer reports whether version a is strictly newer than b. Unparseable +// versions (dev builds) never count as newer or older. +func Newer(a, b string) bool { + pa, okA := parse(a) + pb, okB := parse(b) + if !okA || !okB { + return false + } + for i := range pa { + if pa[i] != pb[i] { + return pa[i] > pb[i] + } + } + return false +} + +func parse(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+ "); i >= 0 { + v = v[:i] + } + parts := strings.Split(v, ".") + if len(parts) < 2 { + return out, false + } + for i := 0; i < 3 && i < len(parts); i++ { + n, err := strconv.Atoi(parts[i]) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} diff --git a/internal/update/method.go b/internal/update/method.go new file mode 100644 index 0000000..318ede7 --- /dev/null +++ b/internal/update/method.go @@ -0,0 +1,95 @@ +package update + +import ( + "os" + "os/exec" + "path/filepath" + "runtime/debug" + "strings" +) + +// Channel is baked in at build time (-X .../update.Channel=pypi) for builds +// published somewhere other than GitHub releases. +var Channel = "" + +// Method is how this binary was installed, and so how to upgrade it. +type Method struct { + Name string // homebrew, pipx, uv, pip, go, binary + Cmd []string // upgrade command; nil means replace the binary in place + Exe string // resolved path of the running binary +} + +// Display is a human-readable upgrade command. +func (m Method) Display() string { + if m.Cmd == nil { + return "download the latest release into " + m.Exe + } + return strings.Join(m.Cmd, " ") +} + +// Detect works out how the running binary was installed. +func Detect() Method { + exe, err := os.Executable() + if err == nil { + if r, err := filepath.EvalSymlinks(exe); err == nil { + exe = r + } + } + return detect(exe, Channel, goInstalled(exe)) +} + +func detect(exe, channel string, fromGo bool) Method { + p := filepath.ToSlash(exe) + bin := filepath.Dir(exe) + env := filepath.Dir(bin) // venv / tool dir for Python-installed builds + switch { + case strings.Contains(p, "/Cellar/") || strings.Contains(p, "/Caskroom/") || strings.Contains(p, "/linuxbrew/"): + return Method{"homebrew", []string{"brew", "upgrade", "spawnpoint"}, exe} + case fileExists(filepath.Join(env, "pipx_metadata.json")) || strings.Contains(p, "/pipx/venvs/"): + return Method{"pipx", []string{"pipx", "upgrade", "spawnpoint"}, exe} + case fileExists(filepath.Join(env, "uv-receipt.toml")) || strings.Contains(p, "/uv/tools/"): + return Method{"uv", []string{"uv", "tool", "upgrade", "spawnpoint"}, exe} + case channel == "pypi": + // pip into a venv or the user/system site: upgrade with the Python + // installed next to it. + for _, py := range []string{"python3", "python"} { + if cand := filepath.Join(bin, py); fileExists(cand) { + return Method{"pip", []string{cand, "-m", "pip", "install", "--upgrade", "spawnpoint"}, exe} + } + } + return Method{"pip", []string{"python3", "-m", "pip", "install", "--upgrade", "spawnpoint"}, exe} + case fromGo: + return Method{"go", []string{"go", "install", "github.com/" + Repo + "@latest"}, exe} + default: + return Method{"binary", nil, exe} + } +} + +// goInstalled reports a `go install`ed build: it carries a module version and +// lives in GOBIN or GOPATH/bin. +func goInstalled(exe string) bool { + info, ok := debug.ReadBuildInfo() + if !ok || info.Main.Version == "" || info.Main.Version == "(devel)" { + return false + } + dir := filepath.Dir(exe) + for _, env := range []string{"GOBIN", "GOPATH"} { + out, err := exec.Command("go", "env", env).Output() + if err != nil { + continue + } + v := strings.TrimSpace(string(out)) + if env == "GOPATH" && v != "" { + v = filepath.Join(filepath.SplitList(v)[0], "bin") + } + if v != "" && v == dir { + return true + } + } + return false +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/update/selfupdate.go b/internal/update/selfupdate.go new file mode 100644 index 0000000..5f83ca0 --- /dev/null +++ b/internal/update/selfupdate.go @@ -0,0 +1,167 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// AssetName is the release archive for this platform, e.g. +// spawnpoint_darwin_arm64.tar.gz (see .goreleaser.yaml). +func AssetName() string { + ext := ".tar.gz" + if runtime.GOOS == "windows" { + ext = ".zip" + } + return fmt.Sprintf("spawnpoint_%s_%s%s", runtime.GOOS, runtime.GOARCH, ext) +} + +var downloadClient = &http.Client{Timeout: 2 * time.Minute} + +func download(url string) ([]byte, error) { + resp, err := downloadClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + return io.ReadAll(resp.Body) +} + +// SelfUpdate downloads a release, verifies its checksum and atomically +// replaces exe. +func SelfUpdate(version, exe string) error { + base := fmt.Sprintf("%s/download/v%s", releasesURL(), version) + asset := AssetName() + + sums, err := download(base + "/checksums.txt") + if err != nil { + return fmt.Errorf("fetching checksums: %w", err) + } + want := checksumFor(sums, asset) + if want == "" { + return fmt.Errorf("no release build for %s/%s (%s)", runtime.GOOS, runtime.GOARCH, asset) + } + + archive, err := download(base + "/" + asset) + if err != nil { + return err + } + got := sha256.Sum256(archive) + if hex.EncodeToString(got[:]) != want { + return fmt.Errorf("checksum mismatch for %s", asset) + } + + bin, err := extractBinary(archive, asset) + if err != nil { + return err + } + return replace(exe, bin) +} + +func checksumFor(sums []byte, asset string) string { + sc := bufio.NewScanner(bytes.NewReader(sums)) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) == 2 && fields[1] == asset { + return fields[0] + } + } + return "" +} + +func extractBinary(archive []byte, asset string) ([]byte, error) { + name := "spawnpoint" + if runtime.GOOS == "windows" { + name += ".exe" + } + if strings.HasSuffix(asset, ".zip") { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, err + } + for _, f := range zr.File { + if filepath.Base(f.Name) != name { + continue + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + return nil, fmt.Errorf("%s not found in %s", name, asset) + } + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, err + } + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return nil, fmt.Errorf("%s not found in %s", name, asset) + } + if err != nil { + return nil, err + } + if h.Typeflag == tar.TypeReg && filepath.Base(h.Name) == name { + return io.ReadAll(tr) + } + } +} + +// replace swaps exe for bin via a temp file and a rename in the same +// directory, so a failed update never leaves a half-written binary. +func replace(exe string, bin []byte) error { + dir := filepath.Dir(exe) + tmp, err := os.CreateTemp(dir, ".spawnpoint-update-*") + if err != nil { + return permissionHint(err, dir) + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(bin); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmp.Name(), 0o755); err != nil { + return err + } + if runtime.GOOS == "windows" { + // A running .exe can't be overwritten, but it can be renamed. + old := exe + ".old" + _ = os.Remove(old) + if err := os.Rename(exe, old); err != nil { + return permissionHint(err, dir) + } + } + if err := os.Rename(tmp.Name(), exe); err != nil { + return permissionHint(err, dir) + } + return nil +} + +func permissionHint(err error, dir string) error { + if os.IsPermission(err) { + return fmt.Errorf("%w\n%s isn't writable: rerun with sudo, or reinstall with the install script", err, dir) + } + return err +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..5306d68 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,117 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestNewer(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"1.0.0", "0.11.0", true}, + {"0.11.1", "0.11.0", true}, + {"0.11.0", "0.11.0", false}, + {"0.10.9", "0.11.0", false}, + {"v1.2.0", "1.1.9", true}, + {"1.0.0", "dev", false}, + {"1.0.0", "0.11.1.dev0+g4860bff", true}, + } + for _, c := range cases { + if got := Newer(c.a, c.b); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +func TestDetect(t *testing.T) { + cases := []struct { + exe, channel string + fromGo bool + want string + }{ + {"/opt/homebrew/Cellar/spawnpoint/1.0.0/bin/spawnpoint", "", false, "homebrew"}, + {"/Users/x/.local/pipx/venvs/spawnpoint/bin/spawnpoint", "pypi", false, "pipx"}, + {"/Users/x/.local/share/uv/tools/spawnpoint/bin/spawnpoint", "pypi", false, "uv"}, + {"/Users/x/proj/.venv/bin/spawnpoint", "pypi", false, "pip"}, + {"/Users/x/go/bin/spawnpoint", "", true, "go"}, + {"/Users/x/.local/bin/spawnpoint", "", false, "binary"}, + } + for _, c := range cases { + if got := detect(c.exe, c.channel, c.fromGo).Name; got != c.want { + t.Errorf("detect(%s) = %s, want %s", c.exe, got, c.want) + } + } +} + +// TestSelfUpdate serves a fake release and checks the binary is swapped in +// place (and that a bad checksum leaves it untouched). +func TestSelfUpdate(t *testing.T) { + newBin := []byte("#!/bin/sh\necho new\n") + var archive bytes.Buffer + gz := gzip.NewWriter(&archive) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{Name: "spawnpoint", Mode: 0o755, Size: int64(len(newBin)), Typeflag: tar.TypeReg}) + tw.Write(newBin) + tw.Close() + gz.Close() + sum := sha256.Sum256(archive.Bytes()) + checksum := hex.EncodeToString(sum[:]) + + mux := http.NewServeMux() + mux.HandleFunc("/latest", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/tag/v9.9.9", http.StatusFound) + }) + mux.HandleFunc("/download/v9.9.9/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s %s\n", checksum, AssetName()) + }) + mux.HandleFunc("/download/v9.9.9/"+AssetName(), func(w http.ResponseWriter, _ *http.Request) { + w.Write(archive.Bytes()) + }) + mux.HandleFunc("/download/v6.6.6/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s %s\n", "deadbeef", AssetName()) + }) + mux.HandleFunc("/download/v6.6.6/"+AssetName(), func(w http.ResponseWriter, _ *http.Request) { + w.Write(archive.Bytes()) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + t.Setenv("SPAWNPOINT_RELEASES_URL", srv.URL) + + if v, err := Latest(); err != nil || v != "9.9.9" { + t.Fatalf("Latest() = %q, %v", v, err) + } + if runtime.GOOS == "windows" { + t.Skip("archive is tar.gz") + } + + exe := filepath.Join(t.TempDir(), "spawnpoint") + os.WriteFile(exe, []byte("old"), 0o755) + + if err := SelfUpdate("6.6.6", exe); err == nil { + t.Fatal("bad checksum accepted") + } + if got, _ := os.ReadFile(exe); string(got) != "old" { + t.Fatal("binary changed despite bad checksum") + } + + if err := SelfUpdate("9.9.9", exe); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(exe) + info, _ := os.Stat(exe) + if !bytes.Equal(got, newBin) || info.Mode().Perm() != 0o755 { + t.Fatalf("binary = %q mode %v", got, info.Mode()) + } +} diff --git a/internal/workspace/artifacts.go b/internal/workspace/artifacts.go new file mode 100644 index 0000000..d4000c2 --- /dev/null +++ b/internal/workspace/artifacts.go @@ -0,0 +1,119 @@ +package workspace + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" +) + +// ArtifactType is a reinstallable directory that's safe to delete. +type ArtifactType struct{ Name, Desc string } + +var ArtifactTypes = []ArtifactType{ + {"node_modules", "Node.js dependencies"}, + {".venv", "Python virtualenv (.venv)"}, + {"venv", "Python virtualenv (venv)"}, + {"env", "Python virtualenv (env)"}, + {".tox", "Tox test environments"}, + {"__pycache__", "Python bytecode cache"}, + {".pytest_cache", "Pytest cache"}, + {"dist", "Build output (dist)"}, + {"build", "Build output (build)"}, + {".next", "Next.js build cache"}, + {".nuxt", "Nuxt.js build cache"}, + {".turbo", "Turborepo cache"}, + {"target", "Rust/Java build artifacts (target)"}, + {".gradle", "Gradle cache"}, + {".parcel-cache", "Parcel build cache"}, + {".cache", "Generic cache directory"}, +} + +var artifactNames = func() map[string]bool { + m := map[string]bool{} + for _, a := range ArtifactTypes { + m[a.Name] = true + } + return m +}() + +// Artifact is one artifact dir found in a workspace. +type Artifact struct { + Path string + Type string + Size int64 +} + +// FindArtifacts walks root (up to 6 levels, skipping symlinks and .git) and +// returns artifact dirs without descending into them. +func FindArtifacts(root string) []Artifact { + var found []Artifact + var walk func(dir string, depth int) + walk = func(dir string, depth int) { + if depth > 6 { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() || e.Type()&fs.ModeSymlink != 0 || e.Name() == ".git" { + continue + } + p := filepath.Join(dir, e.Name()) + if artifactNames[e.Name()] { + found = append(found, Artifact{Path: p, Type: e.Name()}) + } else { + walk(p, depth+1) + } + } + } + walk(root, 0) + return found +} + +// MeasureSizes fills in Size for each artifact, in parallel. +func MeasureSizes(arts []Artifact) { + var wg sync.WaitGroup + sem := make(chan struct{}, workers) + for i := range arts { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + arts[i].Size = dirSize(arts[i].Path) + }() + } + wg.Wait() +} + +func dirSize(root string) int64 { + var total int64 + filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.Type().IsRegular() { + if info, err := d.Info(); err == nil { + total += info.Size() + } + } + return nil + }) + return total +} + +// FormatBytes renders n as "12.3 MB". +func FormatBytes(n int64) string { + f := float64(n) + for _, unit := range []string{"B", "KB", "MB", "GB"} { + if f < 1024 { + return fmt.Sprintf("%.1f %s", f, unit) + } + f /= 1024 + } + return fmt.Sprintf("%.1f TB", f) +} diff --git a/internal/workspace/git.go b/internal/workspace/git.go new file mode 100644 index 0000000..f25f67e --- /dev/null +++ b/internal/workspace/git.go @@ -0,0 +1,123 @@ +// Package workspace implements the git and filesystem work behind every +// command: finding repos, creating and removing worktrees, copying config +// files, installing dependencies, and scanning existing workspaces. +package workspace + +import ( + "bytes" + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Result of a git (or other) subprocess. +type Result struct { + Stdout, Stderr string + Code int +} + +func (r Result) OK() bool { return r.Code == 0 } + +// Run executes name args... in dir, capturing output. A missing binary or a +// timeout reports Code -1. +func Run(dir string, timeout time.Duration, name string, args ...string) Result { + ctx := context.Background() + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + start := time.Now() + err := cmd.Run() + r := Result{Stdout: out.String(), Stderr: errb.String()} + if err != nil { + r.Code = -1 + if ee, ok := err.(*exec.ExitError); ok { + r.Code = ee.ExitCode() + } + } + slog.Debug("exec", "dir", dir, "cmd", name+" "+strings.Join(args, " "), "code", r.Code, "took", time.Since(start).Round(time.Millisecond)) + return r +} + +// Git runs git in dir with no timeout. +func Git(dir string, args ...string) Result { return Run(dir, 0, "git", args...) } + +// RefExists reports whether ref resolves in repo. +func RefExists(repo, ref string) bool { + return Git(repo, "rev-parse", "--verify", "--quiet", ref).OK() +} + +// Prepare fetches, refreshes origin/HEAD and prunes stale worktrees. +func Prepare(repo string) { + Git(repo, "fetch") + Git(repo, "remote", "set-head", "origin", "--auto") + Git(repo, "worktree", "prune") +} + +var headBranchRe = regexp.MustCompile(`HEAD branch:\s+(\S+)`) + +// DetectDefaultBranch finds the repo's default branch: +// 1. refs/remotes/origin/HEAD (local, instant) +// 2. git remote show origin (network, 10s timeout) +// 3. gh repo view (needs gh + auth, 15s timeout) +func DetectDefaultBranch(repo string) string { + if r := Git(repo, "symbolic-ref", "refs/remotes/origin/HEAD"); r.OK() { + ref := strings.TrimSpace(r.Stdout) + if b, ok := strings.CutPrefix(ref, "refs/remotes/origin/"); ok && b != "" { + return b + } + } + if r := Run(repo, 10*time.Second, "git", "remote", "show", "origin"); r.OK() { + if m := headBranchRe.FindStringSubmatch(r.Stdout); m != nil && m[1] != "(unknown)" { + return m[1] + } + } + if r := Run(repo, 15*time.Second, "gh", "repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"); r.OK() { + if b := strings.TrimSpace(r.Stdout); b != "" { + return b + } + } + return "" +} + +// CurrentBranch returns the checked-out branch, or "unknown". +func CurrentBranch(dir string) string { + r := Git(dir, "rev-parse", "--abbrev-ref", "HEAD") + if !r.OK() { + return "unknown" + } + return strings.TrimSpace(r.Stdout) +} + +// IsDirty reports uncommitted changes (including untracked files) in dir. +// Rename detection and optional index locks are skipped: they cost time and +// don't change the answer. +func IsDirty(dir string) bool { + r := Git(dir, "--no-optional-locks", "status", "--porcelain", "--no-renames", "--ignore-submodules=dirty") + return r.OK() && strings.TrimSpace(r.Stdout) != "" +} + +// branchFromGitDir reads the checked-out branch straight from a worktree's +// HEAD file, avoiding a git subprocess. It returns "" if HEAD is detached or +// unreadable. +func branchFromGitDir(gitdir string) string { + data, err := os.ReadFile(filepath.Join(gitdir, "HEAD")) + if err != nil { + return "" + } + ref, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "ref: refs/heads/") + if !ok { + return "" + } + return ref +} diff --git a/internal/workspace/ops.go b/internal/workspace/ops.go new file mode 100644 index 0000000..988f53c --- /dev/null +++ b/internal/workspace/ops.go @@ -0,0 +1,157 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// Action is a planned worktree for one repo. +type Action struct { + Repo Repo + Target string + Branch string + Type string // "add" (branch exists) or "create" (new branch from Base) + Base string + Status string // "created" / "added" on success, "failed" otherwise +} + +// PrepareAll runs Prepare (fetch, set-head, prune) on repos concurrently. +func PrepareAll(repos []Repo) { + var wg sync.WaitGroup + sem := make(chan struct{}, workers) + for _, r := range repos { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + Prepare(r.Path) + }() + } + wg.Wait() +} + +// BranchExists reports whether branch exists locally or on origin. +func BranchExists(repo, branch string) bool { + return RefExists(repo, branch) || RefExists(repo, "origin/"+branch) +} + +// AddWorktree creates the worktree for a. New branches start from the latest +// origin/ when it exists, without tracking it (so `git push` doesn't +// target the base branch). +func AddWorktree(a *Action) error { + if err := os.MkdirAll(filepath.Dir(a.Target), 0o755); err != nil { + return err + } + var r Result + if a.Type == "add" { + r = Git(a.Repo.Path, "worktree", "add", a.Target, a.Branch) + } else { + start := a.Base + if RefExists(a.Repo.Path, "origin/"+a.Base) { + start = "origin/" + a.Base + } + r = Git(a.Repo.Path, "worktree", "add", "--no-track", "-b", a.Branch, a.Target, start) + } + if !r.OK() { + return fmt.Errorf("%s", strings.TrimSpace(r.Stderr)) + } + Git(a.Target, "submodule", "update", "--init", "--recursive") + return nil +} + +// RemoveWorktree removes wt (forcing if dirty) and optionally its branch. +// It reports whether the branch was deleted and any notes worth showing. +func RemoveWorktree(wt Worktree, deleteBranch bool) (branchDeleted bool, notes []string) { + if wt.Parent == "" || !exists(wt.Parent) { + os.RemoveAll(wt.Path) + return false, []string{"parent repo gone, removed directory"} + } + + args := []string{"worktree", "remove", wt.Path} + if wt.Dirty { + args = append(args, "--force") + } + if r := Git(wt.Parent, args...); !r.OK() { + notes = append(notes, "worktree remove failed: "+strings.TrimSpace(r.Stderr)) + if exists(wt.Path) { + os.RemoveAll(wt.Path) + notes = append(notes, "removed directory instead") + } + // Prune so git forgets the worktree (needed before deleting its branch). + Git(wt.Parent, "worktree", "prune") + } + + if !deleteBranch || wt.Branch == "unknown" || wt.Branch == "HEAD" { + return false, notes + } + r := Git(wt.Parent, "branch", "-d", wt.Branch) + if r.OK() { + return true, notes + } + if strings.Contains(r.Stderr, "not fully merged") { + notes = append(notes, fmt.Sprintf("branch '%s' not merged, force-deleted", wt.Branch)) + return Git(wt.Parent, "branch", "-D", wt.Branch).OK(), notes + } + if !strings.Contains(r.Stderr, "not found") { + notes = append(notes, "branch delete skipped: "+strings.TrimSpace(r.Stderr)) + } + return false, notes +} + +// SingleRepoName returns the parent repo name of a single-repo workspace. +func SingleRepoName(ws string) string { + if parent, ok := parentRepo(ws); ok { + return filepath.Base(parent) + } + if gitdir, ok := GitDirOf(ws); ok { + return filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(gitdir)))) + } + return filepath.Base(ws) +} + +// Restructure converts a single-repo workspace (the folder is the worktree) +// into the multi-repo layout (//), fixing git's links both ways. +func Restructure(ws string) (string, error) { + gitdir, ok := GitDirOf(ws) + if !ok { + return "", fmt.Errorf("%s is not a worktree", ws) + } + repoName := SingleRepoName(ws) + + tmp, err := os.MkdirTemp(filepath.Dir(ws), ".sp-restructure-") + if err != nil { + return "", err + } + defer os.RemoveAll(tmp) + + entries, err := os.ReadDir(ws) + if err != nil { + return "", err + } + for _, e := range entries { + if err := os.Rename(filepath.Join(ws, e.Name()), filepath.Join(tmp, e.Name())); err != nil { + return "", err + } + } + sub := filepath.Join(ws, repoName) + if err := os.Rename(tmp, sub); err != nil { + return "", err + } + + // The worktree moved: point its .git at the (absolute) gitdir, and the + // gitdir back at the new location. + if err := os.WriteFile(filepath.Join(sub, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil { + return "", err + } + back := filepath.Join(gitdir, "gitdir") + if exists(back) { + if err := os.WriteFile(back, []byte(filepath.Join(sub, ".git")+"\n"), 0o644); err != nil { + return "", err + } + } + return repoName, nil +} diff --git a/internal/workspace/repos.go b/internal/workspace/repos.go new file mode 100644 index 0000000..64192c3 --- /dev/null +++ b/internal/workspace/repos.go @@ -0,0 +1,86 @@ +package workspace + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// Repo is a git repo found under a scan dir. +type Repo struct { + Name string `json:"name"` // display name, relative to its scan dir + Path string `json:"path"` +} + +// Base is the repo's directory name. +func (r Repo) Base() string { return filepath.Base(r.Path) } + +func exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} + +func isDir(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + +func isFile(p string) bool { + info, err := os.Stat(p) + return err == nil && info.Mode().IsRegular() +} + +func isGitRepo(p string) bool { return exists(filepath.Join(p, ".git")) } + +// FindRepos returns git repos under scanDirs, descending up to depth levels +// but never into a repo. Results are sorted by path and named relative to the +// first scan dir that contains them. +func FindRepos(scanDirs []string, depth int) []Repo { + depth = max(depth, 1) + seen := map[string]bool{} + var paths []string + var walk func(dir string, level int) + walk = func(dir string, level int) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + p := filepath.Join(dir, e.Name()) + if !isDir(p) { + continue + } + if isGitRepo(p) { + if !seen[p] { + seen[p] = true + paths = append(paths, p) + } + } else if level < depth { + walk(p, level+1) + } + } + } + for _, d := range scanDirs { + if isDir(d) { + walk(d, 1) + } + } + sort.Strings(paths) + + repos := make([]Repo, len(paths)) + for i, p := range paths { + repos[i] = Repo{Name: DisplayName(p, scanDirs), Path: p} + } + return repos +} + +// DisplayName makes path relative to the first scan dir containing it. +func DisplayName(path string, scanDirs []string) string { + for _, d := range scanDirs { + if rel, err := filepath.Rel(d, path); err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + return rel + } + } + return path +} diff --git a/internal/workspace/setup.go b/internal/workspace/setup.go new file mode 100644 index 0000000..e84f345 --- /dev/null +++ b/internal/workspace/setup.go @@ -0,0 +1,211 @@ +package workspace + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mihirgupta0900/spawnpoint/internal/config" + "github.com/mihirgupta0900/spawnpoint/internal/ui" +) + +// CopyEssentialFiles copies the configured env/agent files and dirs from the +// source repo into a new worktree. Existing files in the worktree win. +func CopyEssentialFiles(src, dst string, cfg *config.Config) []string { + var copied []string + for _, pattern := range cfg.CopyGlobs { + matches, _ := filepath.Glob(filepath.Join(src, pattern)) + for _, m := range matches { + if !isFile(m) { + continue + } + target := filepath.Join(dst, filepath.Base(m)) + if exists(target) { + continue + } + if err := copyFile(m, target); err != nil { + ui.Hint("%s", ui.Bad(fmt.Sprintf("failed to copy %s: %v", filepath.Base(m), err))) + continue + } + copied = append(copied, filepath.Base(m)) + } + } + for _, name := range cfg.CopyFiles { + from, to := filepath.Join(src, name), filepath.Join(dst, name) + if !isFile(from) || exists(to) { + continue + } + if err := copyFile(from, to); err != nil { + ui.Hint("%s", ui.Bad(fmt.Sprintf("failed to copy %s: %v", name, err))) + continue + } + copied = append(copied, name) + } + for _, name := range cfg.CopyDirs { + from := filepath.Join(src, name) + if !isDir(from) { + continue + } + if err := copyTree(from, filepath.Join(dst, name)); err != nil { + ui.Hint("%s", ui.Bad(fmt.Sprintf("failed to copy %s/: %v", name, err))) + continue + } + copied = append(copied, name+"/") + } + return copied +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Chtimes(dst, info.ModTime(), info.ModTime()) +} + +func copyTree(src, dst string) error { + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + if !isFile(p) { // skip sockets, broken links, etc. + return nil + } + return copyFile(p, target) + }) +} + +type installStep struct { + label string + cmds [][]string +} + +// detectInstalls picks the install commands for a worktree, one per +// ecosystem present. +func detectInstalls(dir string) []installStep { + has := func(name string) bool { return exists(filepath.Join(dir, name)) } + _, uvErr := exec.LookPath("uv") + hasUV := uvErr == nil + + var steps []installStep + switch { + case has("pnpm-lock.yaml"): + steps = append(steps, installStep{"pnpm install", [][]string{{"pnpm", "install"}}}) + case has("bun.lockb") || has("bun.lock"): + steps = append(steps, installStep{"bun install", [][]string{{"bun", "install"}}}) + case has("yarn.lock"): + steps = append(steps, installStep{"yarn install", [][]string{{"yarn", "install"}}}) + case has("package.json"): + steps = append(steps, installStep{"npm install", [][]string{{"npm", "install"}}}) + } + if has("go.mod") { + steps = append(steps, installStep{"go mod download", [][]string{{"go", "mod", "download"}}}) + } + if has("Gemfile") { + steps = append(steps, installStep{"bundle install", [][]string{{"bundle", "install"}}}) + } + switch { + case has("uv.lock") && hasUV: + steps = append(steps, installStep{"uv sync", [][]string{{"uv", "sync"}}}) + case has("poetry.lock"): + steps = append(steps, installStep{"poetry install", [][]string{{"poetry", "install"}}}) + case has("requirements.txt") && hasUV: + steps = append(steps, installStep{"uv pip install -r requirements.txt", [][]string{ + {"uv", "venv"}, {"uv", "pip", "install", "-r", "requirements.txt"}, + }}) + case has("requirements.txt"): + steps = append(steps, installStep{"pip install -r requirements.txt", [][]string{ + {"python3", "-m", "venv", ".venv"}, {filepath.Join(".venv", "bin", "pip"), "install", "-r", "requirements.txt"}, + }}) + } + return steps +} + +// cleanEnv drops variables that would leak the caller's Python/Node +// environment into the new worktree's installs. +func cleanEnv() []string { + var env []string + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "VIRTUAL_ENV=") { + continue + } + env = append(env, kv) + } + return env +} + +// SetupDependencies installs dependencies for every ecosystem detected in +// dir. Install output is shown only if a step fails (or with --debug). +func SetupDependencies(dir string) []string { + var done []string + for _, step := range detectInstalls(dir) { + var out bytes.Buffer + var failed error + ui.Spin("installing deps · "+step.label, func() { + for _, c := range step.cmds { + cmd := exec.Command(c[0], c[1:]...) + if strings.Contains(c[0], string(filepath.Separator)) { + cmd.Path = filepath.Join(dir, c[0]) + } + cmd.Dir, cmd.Env = dir, cleanEnv() + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + } else { + cmd.Stdout, cmd.Stderr = &out, &out + } + if err := cmd.Run(); err != nil { + failed = fmt.Errorf("%s: %w", strings.Join(c, " "), err) + return + } + } + }) + if failed != nil { + ui.Hint("%s", ui.Bad("✗ "+step.label+" failed: "+failed.Error())) + for _, line := range tail(out.String(), 15) { + ui.Hint(" %s", line) + } + continue + } + done = append(done, step.label) + } + return done +} + +func tail(s string, n int) []string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return lines +} diff --git a/internal/workspace/workspaces.go b/internal/workspace/workspaces.go new file mode 100644 index 0000000..168da51 --- /dev/null +++ b/internal/workspace/workspaces.go @@ -0,0 +1,332 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" +) + +// Worktree is one repo checkout inside a workspace. +type Worktree struct { + Path string + Parent string // main checkout the worktree belongs to ("" if gone) + Branch string + Dirty bool + Modified time.Time +} + +// ParentName is the parent repo's directory name, or "unknown". +func (w Worktree) ParentName() string { + if w.Parent == "" { + return "unknown" + } + return filepath.Base(w.Parent) +} + +// Workspace is a folder under the worktree dir: either a single worktree +// (single-repo layout) or a folder of worktrees, one per repo. +type Workspace struct { + Path string + Name string + Worktrees []Worktree +} + +// Oldest is the oldest worktree mtime (the folder's own mtime if empty). +func (w *Workspace) Oldest() time.Time { + if len(w.Worktrees) == 0 { + if info, err := os.Stat(w.Path); err == nil { + return info.ModTime() + } + return time.Time{} + } + oldest := w.Worktrees[0].Modified + for _, wt := range w.Worktrees[1:] { + if wt.Modified.Before(oldest) { + oldest = wt.Modified + } + } + return oldest +} + +func (w *Workspace) Dirty() bool { + for _, wt := range w.Worktrees { + if wt.Dirty { + return true + } + } + return false +} + +// Branches returns the distinct branches checked out, sorted. +func (w *Workspace) Branches() []string { + set := map[string]bool{} + for _, wt := range w.Worktrees { + set[wt.Branch] = true + } + out := make([]string, 0, len(set)) + for b := range set { + out = append(out, b) + } + sort.Strings(out) + return out +} + +// Label is the one-line summary used in pickers. +func (w *Workspace) Label() string { + n := len(w.Worktrees) + repos := "repos" + if n == 1 { + repos = "repo" + } + state := "clean" + if w.Dirty() { + state = "dirty" + } + return fmt.Sprintf("%s (%d %s, %s, %s)", w.Name, n, repos, state, FormatAge(w.Oldest())) +} + +// GitDirOf reads a worktree's .git file and returns the gitdir it points to. +func GitDirOf(worktree string) (string, bool) { + gitFile := filepath.Join(worktree, ".git") + if !isFile(gitFile) { + return "", false + } + data, err := os.ReadFile(gitFile) + if err != nil { + return "", false + } + content := strings.TrimSpace(string(data)) + rest, ok := strings.CutPrefix(content, "gitdir:") + if !ok { + return "", false + } + gitdir := strings.TrimSpace(rest) + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(worktree, gitdir) + } + return filepath.Clean(gitdir), true +} + +// parentRepo resolves the main checkout from a worktree's gitdir, which +// looks like /path/to/repo/.git/worktrees/. +func parentRepo(worktree string) (string, bool) { + gitdir, ok := GitDirOf(worktree) + if !ok { + return "", false + } + parent := filepath.Dir(filepath.Dir(filepath.Dir(gitdir))) + if exists(filepath.Join(parent, ".git")) { + return parent, true + } + return "", false +} + +func scanWorktree(path string) (Worktree, bool) { + parent, ok := parentRepo(path) + if !ok { + return Worktree{}, false + } + info, err := os.Stat(path) + if err != nil { + return Worktree{}, false + } + gitdir, _ := GitDirOf(path) + branch := branchFromGitDir(gitdir) + if branch == "" { + branch = CurrentBranch(path) + } + return Worktree{ + Path: path, + Parent: parent, + Branch: branch, + Dirty: IsDirty(path), + Modified: info.ModTime(), + }, true +} + +// workers bounds concurrent git subprocesses: enough to hide per-process +// latency without thrashing the disk. +var workers = max(8, 2*runtime.NumCPU()) + +// ScanWorkspaces finds workspaces across dirs (deduplicated by resolved +// path), scanning worktrees in parallel. +func ScanWorkspaces(dirs []string) []*Workspace { + type job struct{ ws, wt string } + var jobs []job + var order []string + seen := map[string]bool{} + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + p := filepath.Join(dir, e.Name()) + if !isDir(p) { + continue + } + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + resolved = p + } + if seen[resolved] { + continue + } + seen[resolved] = true + order = append(order, p) + + if isFile(filepath.Join(p, ".git")) { + jobs = append(jobs, job{p, p}) + continue + } + subs, _ := os.ReadDir(p) + for _, s := range subs { + sp := filepath.Join(p, s.Name()) + if isDir(sp) && isFile(filepath.Join(sp, ".git")) { + jobs = append(jobs, job{p, sp}) + } + } + } + } + + results := make([]*Worktree, len(jobs)) + var wg sync.WaitGroup + sem := make(chan struct{}, workers) + for i, j := range jobs { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + if wt, ok := scanWorktree(j.wt); ok { + results[i] = &wt + } + }() + } + wg.Wait() + + byPath := map[string]*Workspace{} + for i, j := range jobs { + if results[i] == nil { + continue + } + ws := byPath[j.ws] + if ws == nil { + ws = &Workspace{Path: j.ws, Name: filepath.Base(j.ws)} + byPath[j.ws] = ws + } + ws.Worktrees = append(ws.Worktrees, *results[i]) + } + var out []*Workspace + for _, p := range order { + if ws := byPath[p]; ws != nil { + out = append(out, ws) + } + } + return out +} + +// SortByAge sorts workspaces by their oldest worktree mtime. +func SortByAge(wss []*Workspace, newestFirst bool) { + sort.SliceStable(wss, func(i, j int) bool { + if newestFirst { + return wss[i].Oldest().After(wss[j].Oldest()) + } + return wss[i].Oldest().Before(wss[j].Oldest()) + }) +} + +// FormatAge renders a coarse "3d ago" style age. +func FormatAge(t time.Time) string { + d := time.Since(t) + days := int(d.Hours() / 24) + switch { + case days == 0: + if h := int(d.Hours()); h > 0 { + return fmt.Sprintf("%dh ago", h) + } + return "just now" + case days < 30: + return fmt.Sprintf("%dd ago", days) + default: + return fmt.Sprintf("%dmo ago", days/30) + } +} + +// Detect reports the workspace containing cwd (searching dirs), along with +// its branch, inferred from an existing worktree in it. +func Detect(cwd string, dirs []string) (workspace, branch string, ok bool) { + if resolved, err := filepath.EvalSymlinks(cwd); err == nil { + cwd = resolved + } + for _, dir := range dirs { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } else { + continue + } + rel, err := filepath.Rel(dir, cwd) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + continue + } + first := strings.Split(rel, string(filepath.Separator))[0] + ws := filepath.Join(dir, first) + if !isDir(ws) { + continue + } + if b := inferBranch(ws); b != "" { + return ws, b, true + } + } + return "", "", false +} + +func inferBranch(ws string) string { + if isFile(filepath.Join(ws, ".git")) { + if r := Git(ws, "rev-parse", "--abbrev-ref", "HEAD"); r.OK() { + return strings.TrimSpace(r.Stdout) + } + } + entries, _ := os.ReadDir(ws) + for _, e := range entries { + p := filepath.Join(ws, e.Name()) + if isDir(p) && isFile(filepath.Join(p, ".git")) { + if r := Git(p, "rev-parse", "--abbrev-ref", "HEAD"); r.OK() { + return strings.TrimSpace(r.Stdout) + } + } + } + return "" +} + +// RepoNamesIn returns the repo (worktree dir) names present in a workspace. +func RepoNamesIn(ws string) map[string]bool { + names := map[string]bool{} + if isFile(filepath.Join(ws, ".git")) { + // Single-repo layout: the folder is named after the branch, so use + // the parent repo's name. + if parent, ok := parentRepo(ws); ok { + names[filepath.Base(parent)] = true + } + } + entries, _ := os.ReadDir(ws) + for _, e := range entries { + p := filepath.Join(ws, e.Name()) + if isDir(p) && isFile(filepath.Join(p, ".git")) { + names[e.Name()] = true + } + } + return names +} + +// IsSingleRepo reports whether ws is itself a worktree (single-repo layout). +func IsSingleRepo(ws string) bool { return isFile(filepath.Join(ws, ".git")) } + +// Exists reports whether p exists. +func Exists(p string) bool { return exists(p) } diff --git a/main.go b/main.go new file mode 100644 index 0000000..89cffed --- /dev/null +++ b/main.go @@ -0,0 +1,35 @@ +// Spawnpoint gives every task its own folder: a git worktree for each repo it +// touches, all on the same branch, with env files copied and dependencies +// installed. +package main + +import ( + "os" + "runtime/debug" + + "github.com/mihirgupta0900/spawnpoint/internal/cli" +) + +// version is set at release time with -ldflags "-X main.version=1.2.3". +var version = "" + +func main() { + os.Exit(cli.Execute(resolveVersion())) +} + +// resolveVersion prefers the release version, then the module version (for +// `go install ...@v1.2.3`), then "dev". +func resolveVersion() string { + if version != "" { + return version + } + if info, ok := debug.ReadBuildInfo(); ok { + if v := info.Main.Version; v != "" && v != "(devel)" { + if v[0] == 'v' { + v = v[1:] + } + return v + } + } + return "dev" +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 02f7cd7..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,53 +0,0 @@ -[project] -name = "spawnpoint" -description = "Spawn multi-repo worktree workspaces for feature development" -readme = "README.md" -requires-python = ">=3.10" -license = "MIT" -authors = [ - { name = "Mihir Gupta", email = "mihirgupta0900@gmail.com" }, -] -keywords = ["git", "worktree", "workspace", "monorepo", "developer-tools"] -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Topic :: Software Development :: Version Control :: Git", -] -dynamic = ["version"] -dependencies = [ - "typer>=0.9", - "InquirerPy>=0.3", - "rich>=13", -] - -[dependency-groups] -dev = ["pytest>=8"] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[project.urls] -Homepage = "https://github.com/mihirgupta0900/spawnpoint" -Repository = "https://github.com/mihirgupta0900/spawnpoint" -Issues = "https://github.com/mihirgupta0900/spawnpoint/issues" - -[project.scripts] -spawnpoint = "spawnpoint.cli:app" -sp = "spawnpoint.cli:app" - -[build-system] -requires = ["hatchling", "hatch-vcs"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "vcs" -fallback-version = "0.1.0" - -[tool.hatch.build.hooks.vcs] -version-file = "src/spawnpoint/_version.py" - -[tool.hatch.build.targets.wheel] -packages = ["src/spawnpoint"] diff --git a/scripts/build_wheels.py b/scripts/build_wheels.py new file mode 100644 index 0000000..ef66043 --- /dev/null +++ b/scripts/build_wheels.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Pack GoReleaser's `pypi` builds into platform wheels for PyPI. + +Each wheel installs the native binary as `spawnpoint` (plus an `sp` alias) +into the environment's bin/Scripts dir, the same way ruff and uv ship. That +keeps `pipx install spawnpoint` / `pipx upgrade spawnpoint` working for +everyone who installed the original Python release: the next upgrade simply +swaps in the Go binary. + +Usage: python scripts/build_wheels.py [--dist dist] [--out dist/wheels] +Reads dist/artifacts.json and dist/metadata.json written by `goreleaser`. +""" + +import argparse +import base64 +import hashlib +import json +import os +import stat +import zipfile +from pathlib import Path + +NAME = "spawnpoint" +SUMMARY = "One branch, every repo, ready to code: multi-repo git worktree workspaces for you and your coding agents" + +# Go 1.26 binaries need macOS 12+. Static (CGO-free) Linux binaries run on +# both glibc and musl. +PLATFORM_TAGS = { + ("darwin", "amd64"): ["macosx_12_0_x86_64"], + ("darwin", "arm64"): ["macosx_12_0_arm64"], + ("linux", "amd64"): ["manylinux_2_17_x86_64", "manylinux2014_x86_64", "musllinux_1_1_x86_64"], + ("linux", "arm64"): ["manylinux_2_17_aarch64", "manylinux2014_aarch64", "musllinux_1_1_aarch64"], + ("windows", "amd64"): ["win_amd64"], +} + +# `sp` on unix: hand off to the spawnpoint binary installed next to it +# (pipx/uv link both into the same bin dir). +SP_SHIM = b"""#!/bin/sh +dir=$(dirname "$0") +if [ -x "$dir/spawnpoint" ]; then exec "$dir/spawnpoint" "$@"; fi +exec spawnpoint "$@" +""" + + +def record_line(path: str, data: bytes) -> str: + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() + return f"{path},sha256={digest},{len(data)}" + + +def metadata(version: str, readme: str) -> bytes: + lines = [ + "Metadata-Version: 2.1", + f"Name: {NAME}", + f"Version: {version}", + f"Summary: {SUMMARY}", + "Home-page: https://github.com/mihirgupta0900/spawnpoint", + "Author: Mihir Gupta", + "License: MIT", + "Project-URL: Repository, https://github.com/mihirgupta0900/spawnpoint", + "Project-URL: Issues, https://github.com/mihirgupta0900/spawnpoint/issues", + "Keywords: git,worktree,workspace,multi-repo,ai-agents,claude-code,codex", + "Classifier: Environment :: Console", + "Classifier: Intended Audience :: Developers", + "Classifier: License :: OSI Approved :: MIT License", + "Classifier: Programming Language :: Go", + "Classifier: Topic :: Software Development :: Version Control :: Git", + "Description-Content-Type: text/markdown", + "", + readme, + ] + return "\n".join(lines).encode() + + +def build_wheel(binary: Path, goos: str, goarch: str, version: str, readme: str, out: Path) -> Path: + tags = PLATFORM_TAGS[(goos, goarch)] + plat = ".".join(tags) + wheel = out / f"{NAME}-{version}-py3-none-{plat}.whl" + data_dir = f"{NAME}-{version}.data/scripts" + info_dir = f"{NAME}-{version}.dist-info" + exe = ".exe" if goos == "windows" else "" + bin_bytes = binary.read_bytes() + + files = [(f"{data_dir}/{NAME}{exe}", bin_bytes, True)] + if goos == "windows": + files.append((f"{data_dir}/sp.exe", bin_bytes, True)) + else: + files.append((f"{data_dir}/sp", SP_SHIM, True)) + files.append((f"{info_dir}/METADATA", metadata(version, readme), False)) + wheel_meta = ["Wheel-Version: 1.0", "Generator: spawnpoint build_wheels.py", "Root-Is-Purelib: false"] + wheel_meta += [f"Tag: py3-none-{t}" for t in tags] + files.append((f"{info_dir}/WHEEL", ("\n".join(wheel_meta) + "\n").encode(), False)) + license_path = Path("LICENSE") + if license_path.exists(): + files.append((f"{info_dir}/licenses/LICENSE", license_path.read_bytes(), False)) + + records = [record_line(p, d) for p, d, _ in files] + [f"{info_dir}/RECORD,,"] + files.append((f"{info_dir}/RECORD", ("\n".join(records) + "\n").encode(), False)) + + with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path, data, executable in files: + info = zipfile.ZipInfo(path, date_time=(2020, 1, 1, 0, 0, 0)) + mode = 0o755 if executable else 0o644 + info.external_attr = (stat.S_IFREG | mode) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, data) + return wheel + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--dist", default="dist") + parser.add_argument("--out", default=None) + parser.add_argument("--version", default=None, help="override the version (snapshot builds aren't PEP 440)") + args = parser.parse_args() + + dist = Path(args.dist) + out = Path(args.out or dist / "wheels") + out.mkdir(parents=True, exist_ok=True) + version = args.version or json.loads((dist / "metadata.json").read_text())["version"] + readme = Path("README.md").read_text() if Path("README.md").exists() else "" + + built = [] + for art in json.loads((dist / "artifacts.json").read_text()): + if art.get("type") != "Binary" or art.get("extra", {}).get("ID") != "pypi": + continue + key = (art["goos"], art["goarch"]) + if key not in PLATFORM_TAGS: + continue + built.append(build_wheel(Path(art["path"]), *key, version, readme, out)) + + if len(built) != len(PLATFORM_TAGS): + raise SystemExit(f"expected {len(PLATFORM_TAGS)} wheels, built {len(built)}") + for w in sorted(built): + print(f"{w} ({os.path.getsize(w) / 1e6:.1f} MB)") + + +if __name__ == "__main__": + main() diff --git a/skills/spawnpoint/references/json_schemas.md b/skills/spawnpoint/references/json_schemas.md index 7f857fc..a49fb2d 100644 --- a/skills/spawnpoint/references/json_schemas.md +++ b/skills/spawnpoint/references/json_schemas.md @@ -44,14 +44,14 @@ by `repos --json` / `list --json`. Unknown or ambiguous names exit non-zero with "name": "api", "branch": "feat/login", "base": "main", - "action": "new_branch | existing_branch | checkout", - "status": "created | success | failed" + "action": "create | add", + "status": "created | failed" } ] } ``` -`template` is the template the repo set came from, or `null` when repos were passed explicitly. +`action` is `create` when the branch was new in that repo (branched from `base`) and `add` when it already existed locally or on origin (`base` is then `null`). `template` is the template the repo set came from, or `null` when repos were passed explicitly. Without `--json` (but with `--no-input`): stdout is just the workspace path; status text is on stderr. @@ -64,7 +64,7 @@ Same shape as create, but the per-repo list key is `added` instead of `repos`: "workspace": "/Users/x/.spawnpoint/workspaces/feat-login", "branch": "feat/login", "added": [ - { "name": "worker", "branch": "feat/login", "base": "main", "action": "new_branch", "status": "created" } + { "name": "worker", "branch": "feat/login", "base": "main", "action": "create", "status": "added" } ] } ``` diff --git a/src/spawnpoint/__init__.py b/src/spawnpoint/__init__.py deleted file mode 100644 index 6e54be6..0000000 --- a/src/spawnpoint/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Spawnpoint — Spawn multi-repo worktree workspaces for feature development.""" - -try: - from ._version import __version__ -except ImportError: - __version__ = "0.1.0" diff --git a/src/spawnpoint/add.py b/src/spawnpoint/add.py deleted file mode 100644 index 6b81644..0000000 --- a/src/spawnpoint/add.py +++ /dev/null @@ -1,408 +0,0 @@ -import subprocess -import time -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import typer -from InquirerPy import inquirer -from rich.console import Console -from rich.progress import track - -from .config import Config -from .io import emit_json, parse_csv, require, resolve_repos -from .log import logger -from .prompts import select_repos -from .utils import ( - copy_essential_files, - detect_default_branch, - find_git_repos, - make_display_path, - setup_dependencies, -) - -console = Console(stderr=True) - - -def _detect_workspace(cfg: Config) -> Optional[Tuple[Path, str]]: - """Detect if cwd is inside a spawnpoint workspace. - - Returns (workspace_dir, branch_name) or None. - Workspace dir is the branch folder (e.g. worktree_dir//). - """ - cwd = Path.cwd().resolve() - - all_worktree_dirs = [cfg.worktree_dir] + [ - d for d in cfg.additional_worktree_dirs if d != cfg.worktree_dir - ] - - for worktree_dir in all_worktree_dirs: - worktree_dir = worktree_dir.resolve() - if not worktree_dir.exists(): - continue - - # Check if cwd is under worktree_dir - try: - rel = cwd.relative_to(worktree_dir) - except ValueError: - continue - - # The first component of the relative path is the branch folder - parts = rel.parts - if not parts: - continue - - workspace_dir = worktree_dir / parts[0] - if not workspace_dir.is_dir(): - continue - - # Infer branch name from an existing worktree in this workspace - branch_name = _infer_branch_name(workspace_dir) - if branch_name: - return workspace_dir, branch_name - - return None - - -def _infer_branch_name(workspace_dir: Path) -> Optional[str]: - """Get branch name from existing worktrees in a workspace.""" - # Check if workspace_dir itself is a worktree (single-repo case) - if (workspace_dir / ".git").is_file(): - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=workspace_dir, capture_output=True, text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - - # Multi-repo: check subdirectories - for sub in sorted(workspace_dir.iterdir()): - if sub.is_dir() and (sub / ".git").is_file(): - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=sub, capture_output=True, text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - - return None - - -def _existing_repo_names(workspace_dir: Path) -> set[str]: - """Get names of repos already in this workspace.""" - names = set() - - # Single-repo workspace: the workspace dir itself is a worktree - if (workspace_dir / ".git").is_file(): - names.add(workspace_dir.name) - - # Multi-repo: subdirectories that are worktrees - for sub in workspace_dir.iterdir(): - if sub.is_dir() and (sub / ".git").is_file(): - names.add(sub.name) - - return names - - -def run_add( - cfg: Config, - *, - no_input: bool = False, - repos_arg: str | None = None, - base: str | None = None, - json_output: bool = False, -): - """Add repos to an existing spawnpoint workspace.""" - detected = _detect_workspace(cfg) - - if not detected: - console.print("[bold red]Error:[/bold red] Not inside a spawnpoint workspace.") - console.print("Run [bold]spawnpoint create[/bold] to create a new workspace,") - console.print("or cd into an existing workspace first.") - raise typer.Exit(code=1) - - workspace_dir, branch_name = detected - existing_names = _existing_repo_names(workspace_dir) - - console.print(f"[bold blue]Workspace:[/bold blue] {workspace_dir.name}") - console.print(f"[bold blue]Branch:[/bold blue] {branch_name}") - if existing_names: - console.print(f"[dim]Already present: {', '.join(sorted(existing_names))}[/dim]") - - # Scan for repos - valid_dirs = [d for d in cfg.scan_dirs if d.is_dir()] - if not valid_dirs: - console.print("[bold red]Error:[/bold red] No valid scan directories configured.") - raise typer.Exit(code=1) - - console.print(f"\n[bold blue]Scanning for git repositories...[/bold blue]") - start = time.monotonic() - repos = find_git_repos(valid_dirs, cfg.scan_depth) - logger.debug("Repo scan completed in %.2fs, found %d repos", time.monotonic() - start, len(repos)) - - # Filter out repos already in the workspace - repos = [r for r in repos if r.name not in existing_names] - - if not repos: - console.print("[yellow]No additional repositories found to add.[/yellow]") - raise typer.Exit() - - choices = [make_display_path(repo, valid_dirs) for repo in repos] - choice_to_path = dict(zip(choices, repos)) - - if no_input: - requested = parse_csv(require(repos_arg, "--repos", console)) - selected_repos = resolve_repos(requested, choice_to_path, err=console) - else: - selected_labels = select_repos( - "Select repositories to add (type to search):", - choices, - ) - - if not selected_labels: - console.print("No repositories selected. Exiting.") - raise typer.Exit() - - selected_repos = [choice_to_path[label] for label in selected_labels] - - # If existing workspace was single-repo, we need to restructure it to multi-repo - needs_restructure = (workspace_dir / ".git").is_file() - - # Fetch & prune - console.print(f"\n[bold blue]Preparing repositories...[/bold blue]") - for repo_path in track(selected_repos, description="Fetching & pruning..."): - try: - subprocess.run(["git", "fetch"], cwd=repo_path, capture_output=True) - subprocess.run( - ["git", "remote", "set-head", "origin", "--auto"], - cwd=repo_path, capture_output=True, - ) - subprocess.run(["git", "worktree", "prune"], cwd=repo_path, capture_output=True) - except Exception as e: - console.print(f"[yellow]Warning fetching {repo_path.name}: {e}[/yellow]") - - # Configure actions - repo_actions: List[Dict[str, Any]] = [] - - for repo_path in selected_repos: - repo_name = repo_path.name - target_path = (workspace_dir / repo_name).resolve() - - if target_path.exists(): - console.print(f"[yellow]Skipping {repo_name}: already exists in workspace.[/yellow]") - continue - - local_exists = subprocess.run( - ["git", "rev-parse", "--verify", branch_name], - cwd=repo_path, capture_output=True, - ).returncode == 0 - - remote_exists = False - if not local_exists: - remote_exists = subprocess.run( - ["git", "rev-parse", "--verify", f"origin/{branch_name}"], - cwd=repo_path, capture_output=True, - ).returncode == 0 - - if local_exists or remote_exists: - repo_actions.append({ - "type": "add", - "repo_path": repo_path, - "target_path": target_path, - "repo_name": repo_name, - "branch": branch_name, - }) - else: - detected_default = detect_default_branch(repo_path) - if base: - # An explicit --base applies to every repo. - base_branch = base - console.print(f" [dim]{repo_name}: creating from {base_branch}[/dim]") - elif no_input: - base_branch = detected_default - if not base_branch: - console.print( - f"[bold red]Error:[/bold red] [{repo_name}] branch '{branch_name}' " - f"not found and no default branch detected. Pass --base." - ) - raise typer.Exit(code=1) - console.print(f" [dim]{repo_name}: creating from {base_branch}[/dim]") - elif detected_default: - choices_list = [detected_default, "Other (manual input)"] - base_branch = inquirer.select( - message=f"[{repo_name}] Branch '{branch_name}' not found. Create from:", - choices=choices_list, - default=detected_default, - ).execute() - if base_branch == "Other (manual input)": - base_branch = inquirer.text(message=f"[{repo_name}] Enter base branch:").execute() - else: - base_branch = inquirer.text( - message=f"[{repo_name}] Branch '{branch_name}' not found. Enter base branch:", - ).execute() - - repo_actions.append({ - "type": "create", - "repo_path": repo_path, - "target_path": target_path, - "repo_name": repo_name, - "branch": branch_name, - "base": base_branch, - }) - - if not repo_actions: - console.print("[yellow]No actions to perform. Exiting.[/yellow]") - raise typer.Exit() - - # Show plan - if needs_restructure: - console.print(f"\n[bold yellow]Note:[/bold yellow] Workspace will be converted from single-repo to multi-repo layout.") - console.print(f" The existing worktree will be moved into {workspace_dir.name}/{existing_names.pop()}/") - - console.print(f"\n[bold]Plan:[/bold]") - for action in repo_actions: - if action["type"] == "add": - console.print(f" {action['repo_name']}: [green]add worktree[/green] for '{action['branch']}'") - else: - console.print(f" {action['repo_name']}: [blue]create branch[/blue] '{action['branch']}' from '{action['base']}'") - - if not no_input and not inquirer.confirm(message="Proceed?", default=True).execute(): - console.print("Aborted.") - raise typer.Exit() - - # Restructure single-repo workspace to multi-repo if needed - if needs_restructure: - _restructure_to_multi_repo(workspace_dir) - - # Execute - for action in track(repo_actions, description="Adding repos..."): - repo_name = action["repo_name"] - repo_path = action["repo_path"] - target_path = action["target_path"] - action["status"] = "failed" - - console.print(f"Processing [bold]{repo_name}[/bold]...") - - try: - target_path.parent.mkdir(parents=True, exist_ok=True) - success = False - - if action["type"] == "add": - cmd = ["git", "worktree", "add", str(target_path), action["branch"]] - result = subprocess.run(cmd, cwd=repo_path, text=True, capture_output=True) - if result.returncode == 0: - success = True - console.print(f" [green]Worktree created.[/green]") - else: - console.print(f" [red]Failed:[/red] {result.stderr.strip()}") - - elif action["type"] == "create": - # Resolve to remote tip if available (fetch already ran in Phase 1) - start_point = action["base"] - check = subprocess.run( - ["git", "rev-parse", "--verify", f"origin/{action['base']}"], - cwd=repo_path, capture_output=True, - ) - if check.returncode == 0: - start_point = f"origin/{action['base']}" - - cmd = ["git", "worktree", "add", "-b", action["branch"], str(target_path), start_point] - result = subprocess.run(cmd, cwd=repo_path, text=True, capture_output=True) - if result.returncode == 0: - success = True - console.print(f" [green]Created from {action['base']}.[/green]") - else: - console.print(f" [red]Failed:[/red] {result.stderr.strip()}") - - if success: - action["status"] = "added" - console.print(f" [dim]Initializing submodules...[/dim]") - subprocess.run( - ["git", "submodule", "update", "--init", "--recursive"], - cwd=target_path, capture_output=True, - ) - copy_essential_files(repo_path, target_path, cfg) - - if cfg.auto_install_deps: - setup_dependencies(target_path) - - except Exception as e: - console.print(f"[red]Error processing {repo_name}: {e}[/red]") - - console.print(f"\n[bold green]Done![/bold green]") - console.print(f"Workspace: [bold blue]{workspace_dir}[/bold blue]") - - if json_output: - emit_json({ - "workspace": str(workspace_dir), - "branch": branch_name, - "added": [ - { - "name": a["repo_name"], - "branch": a["branch"], - "base": a.get("base"), - "action": a["type"], - "status": a.get("status", "failed"), - } - for a in repo_actions - ], - }) - - -def _restructure_to_multi_repo(workspace_dir: Path): - """Convert a single-repo workspace into a multi-repo layout. - - Moves worktree_dir// contents into worktree_dir///. - The repo_name is inferred from the git remote or the parent repo. - """ - import shutil - import tempfile - - # Read the .git file to find the parent repo name - git_file = workspace_dir / ".git" - content = git_file.read_text().strip() - gitdir = Path(content.split("gitdir:", 1)[1].strip()) - if not gitdir.is_absolute(): - gitdir = (workspace_dir / gitdir).resolve() - - # gitdir is like /path/to/repo/.git/worktrees/ - parent_repo = gitdir.parent.parent.parent - repo_name = parent_repo.name - - console.print(f" [dim]Restructuring: moving existing worktree into {repo_name}/[/dim]") - - # Move workspace contents to a temp dir, then back into a subdirectory - tmp = Path(tempfile.mkdtemp(dir=workspace_dir.parent)) - try: - # Move everything from workspace to temp - for item in workspace_dir.iterdir(): - shutil.move(str(item), str(tmp / item.name)) - - # Create the subdirectory and move everything back - sub_dir = workspace_dir / repo_name - sub_dir.mkdir() - for item in tmp.iterdir(): - shutil.move(str(item), str(sub_dir / item.name)) - finally: - if tmp.exists(): - shutil.rmtree(tmp, ignore_errors=True) - - # Update the .git file's gitdir path since the worktree moved - new_git_file = sub_dir / ".git" - old_content = new_git_file.read_text().strip() - old_gitdir_str = old_content.split("gitdir:", 1)[1].strip() - old_gitdir = Path(old_gitdir_str) - - if not old_gitdir.is_absolute(): - # Relative path needs updating: was relative to workspace_dir, now relative to sub_dir - abs_gitdir = (workspace_dir / old_gitdir).resolve() - # Make it absolute to be safe - new_git_file.write_text(f"gitdir: {abs_gitdir}\n") - # If absolute, no change needed - - # Also update the worktree config in the parent repo's .git/worktrees//gitdir - gitdir_resolved = gitdir if gitdir.is_absolute() else (workspace_dir / gitdir).resolve() - gitdir_file = gitdir_resolved / "gitdir" - if gitdir_file.exists(): - gitdir_file.write_text(str(sub_dir / ".git") + "\n") - - console.print(f" [green]Restructured successfully.[/green]") diff --git a/src/spawnpoint/cleanup.py b/src/spawnpoint/cleanup.py deleted file mode 100644 index 6e3ce20..0000000 --- a/src/spawnpoint/cleanup.py +++ /dev/null @@ -1,353 +0,0 @@ -import shutil -import subprocess -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import List, Optional - -import typer -from InquirerPy import inquirer -from rich.console import Console -from rich.progress import track - -from .config import Config -from .io import emit_json, parse_csv, resolve_names -from .log import logger - -console = Console() - - -@dataclass -class WorktreeInfo: - worktree_path: Path - parent_repo_path: Optional[Path] - branch_name: str - is_dirty: bool - last_modified: datetime - - -@dataclass -class BranchFolder: - path: Path - name: str - worktrees: List[WorktreeInfo] = field(default_factory=list) - - @property - def oldest_modified(self) -> datetime: - if not self.worktrees: - return datetime.fromtimestamp(self.path.stat().st_mtime, tz=timezone.utc) - return min(w.last_modified for w in self.worktrees) - - @property - def any_dirty(self) -> bool: - return any(w.is_dirty for w in self.worktrees) - - -def _parse_git_file(path: Path) -> Optional[Path]: - """Parse a .git file to extract the gitdir path and resolve the parent repo.""" - git_path = path / ".git" - if not git_path.is_file(): - return None - try: - content = git_path.read_text().strip() - if not content.startswith("gitdir:"): - return None - gitdir = Path(content.split("gitdir:", 1)[1].strip()) - if not gitdir.is_absolute(): - gitdir = (path / gitdir).resolve() - # gitdir is like /path/to/repo/.git/worktrees/ - parent_repo = gitdir.parent.parent.parent - if (parent_repo / ".git").exists(): - return parent_repo - except Exception: - pass - return None - - -def _get_branch_name(path: Path) -> str: - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=path, capture_output=True, text=True, - ) - return result.stdout.strip() if result.returncode == 0 else "unknown" - - -def _is_dirty(path: Path) -> bool: - result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=path, capture_output=True, text=True, - ) - return bool(result.stdout.strip()) if result.returncode == 0 else False - - -def _get_mtime(path: Path) -> datetime: - return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) - - -def _scan_worktree(path: Path) -> Optional[WorktreeInfo]: - logger.debug("Scanning worktree: %s", path) - parent_repo = _parse_git_file(path) - if parent_repo is None: - logger.debug(" Not a worktree (no .git file): %s", path) - return None - branch = _get_branch_name(path) - dirty = _is_dirty(path) - mtime = _get_mtime(path) - logger.debug(" branch=%s dirty=%s parent=%s", branch, dirty, parent_repo) - return WorktreeInfo( - worktree_path=path, - parent_repo_path=parent_repo, - branch_name=branch, - is_dirty=dirty, - last_modified=mtime, - ) - - -def _scan_work_dir(work_dir: Path) -> List[BranchFolder]: - if not work_dir.exists(): - return [] - - logger.debug("Scanning work dir: %s", work_dir) - - # Collect all worktree paths to scan - entries: List[tuple[Path, Path]] = [] # (branch_folder, worktree_path) - seen_paths: set[Path] = set() - - for entry in sorted(work_dir.iterdir()): - if not entry.is_dir(): - continue - resolved = entry.resolve() - if resolved in seen_paths: - continue - seen_paths.add(resolved) - - git_file = entry / ".git" - if git_file.is_file(): - entries.append((entry, entry)) - else: - for sub in sorted(entry.iterdir()): - if sub.is_dir() and (sub / ".git").is_file(): - entries.append((entry, sub)) - - logger.debug("Found %d worktree paths to scan", len(entries)) - - # Scan all worktrees in parallel - results: dict[Path, List[WorktreeInfo]] = {} - with ThreadPoolExecutor(max_workers=8) as pool: - futures = {pool.submit(_scan_worktree, wt_path): (bf_path, wt_path) for bf_path, wt_path in entries} - for future in futures: - bf_path, wt_path = futures[future] - wt = future.result() - if wt: - results.setdefault(bf_path, []).append(wt) - - folders: List[BranchFolder] = [] - for bf_path, worktrees in results.items(): - bf = BranchFolder(path=bf_path, name=bf_path.name, worktrees=worktrees) - folders.append(bf) - - logger.debug("Found %d workspaces with worktrees", len(folders)) - return folders - - -def _format_age(dt: datetime) -> str: - delta = datetime.now(tz=timezone.utc) - dt - days = delta.days - if days == 0: - hours = delta.seconds // 3600 - return f"{hours}h ago" if hours > 0 else "just now" - if days < 30: - return f"{days}d ago" - return f"{days // 30}mo ago" - - -def _format_choice(bf: BranchFolder) -> str: - repo_count = len(bf.worktrees) - dirty_str = "dirty" if bf.any_dirty else "clean" - age = _format_age(bf.oldest_modified) - repo_label = "repo" if repo_count == 1 else "repos" - return f"{bf.name} ({repo_count} {repo_label}, {dirty_str}, {age})" - - -def _remove_worktree(wt: WorktreeInfo, delete_branch: bool) -> bool: - """Remove a worktree (and optionally its branch). Returns True if branch deleted.""" - parent = wt.parent_repo_path - - if parent is None or not parent.exists(): - console.print(f" [yellow]Parent repo gone, removing directory: {wt.worktree_path}[/yellow]") - shutil.rmtree(wt.worktree_path, ignore_errors=True) - return False - - cmd = ["git", "worktree", "remove", str(wt.worktree_path)] - if wt.is_dirty: - cmd.append("--force") - console.print(f" [yellow]Force-removing dirty worktree:[/yellow] {wt.worktree_path.name}") - else: - console.print(f" Removing worktree: {wt.worktree_path.name}") - - result = subprocess.run(cmd, cwd=parent, capture_output=True, text=True) - if result.returncode != 0: - err = result.stderr.strip() - console.print(f" [red]worktree remove failed: {err}[/red]") - if wt.worktree_path.exists(): - console.print(f" [yellow]Falling back to rmtree[/yellow]") - shutil.rmtree(wt.worktree_path, ignore_errors=True) - # Prune so git forgets the removed worktree (needed for branch delete) - subprocess.run(["git", "worktree", "prune"], cwd=parent, capture_output=True) - - if delete_branch and wt.branch_name not in ("unknown", "HEAD"): - result = subprocess.run( - ["git", "branch", "-d", wt.branch_name], - cwd=parent, capture_output=True, text=True, - ) - if result.returncode == 0: - return True - err = result.stderr.strip() - if "not fully merged" in err: - console.print(f" [yellow]Branch '{wt.branch_name}' not merged, force-deleting[/yellow]") - forced = subprocess.run( - ["git", "branch", "-D", wt.branch_name], - cwd=parent, capture_output=True, text=True, - ) - return forced.returncode == 0 - elif "not found" not in err: - console.print(f" [dim]Branch delete skipped: {err}[/dim]") - - return False - - -def run_cleanup( - cfg: Config, - *, - no_input: bool = False, - workspaces: str | None = None, - delete_branches: bool | None = None, - json_output: bool = False, -): - """Remove worktree workspaces.""" - work_dirs = [cfg.worktree_dir] + [ - d for d in cfg.additional_worktree_dirs if d != cfg.worktree_dir - ] - existing_dirs = [d for d in work_dirs if d.exists()] - - if not existing_dirs: - console.print("[yellow]No workspaces directory found.[/yellow]") - raise typer.Exit() - - console.print("[bold blue]Scanning for worktree workspaces...[/bold blue]") - for d in existing_dirs: - console.print(f"[dim]{d}[/dim]") - - folders: List[BranchFolder] = [] - seen_paths: set[Path] = set() - for work_dir in existing_dirs: - for bf in _scan_work_dir(work_dir): - resolved = bf.path.resolve() - if resolved not in seen_paths: - seen_paths.add(resolved) - folders.append(bf) - - if not folders: - console.print("[yellow]No worktree workspaces found.[/yellow]") - raise typer.Exit() - - # Sort oldest-first - folders.sort(key=lambda bf: bf.oldest_modified) - - folder_map = {_format_choice(bf): bf for bf in folders} - - if no_input: - if not workspaces: - console.print("[bold red]Error:[/bold red] --no-input requires --workspaces.") - raise typer.Exit(code=1) - name_to_bf = {bf.name: bf for bf in folders} - requested = parse_csv(workspaces) - selected = resolve_names(requested, name_to_bf, kind="workspace", err=console) - if delete_branches is None: - console.print( - "[bold red]Error:[/bold red] --no-input requires --delete-branches or --keep-branches." - ) - raise typer.Exit(code=1) - branch_pref = "Delete all branches" if delete_branches else "Keep branches" - else: - selected_labels = inquirer.fuzzy( - message="Select workspaces to remove (type to search):", - choices=list(folder_map.keys()), - multiselect=True, - ).execute() - - if not selected_labels: - console.print("No workspaces selected. Exiting.") - raise typer.Exit() - - selected = [folder_map[label] for label in selected_labels] - - branch_pref = inquirer.select( - message="Delete branches from parent repos?", - choices=["Delete all branches", "Keep branches", "Ask per branch"], - ).execute() - - # Show plan - console.print(f"\n[bold]Plan:[/bold]") - for bf in selected: - console.print(f"\n [bold]{bf.name}/[/bold]") - for wt in bf.worktrees: - dirty_tag = " [yellow](dirty — will force)[/yellow]" if wt.is_dirty else "" - parent_label = wt.parent_repo_path.name if wt.parent_repo_path else "unknown" - console.print(f" {parent_label}: git worktree remove{dirty_tag}") - if branch_pref == "Delete all branches": - console.print(f" {parent_label}: git branch -d {wt.branch_name}") - elif branch_pref == "Ask per branch": - console.print(f" {parent_label}: [dim]will ask about '{wt.branch_name}'[/dim]") - - console.print("") - if not no_input and not inquirer.confirm(message="Proceed with cleanup?").execute(): - console.print("Aborted.") - raise typer.Exit() - - # Execute - parent_repos_seen: set[Path] = set() - removed_report: List[dict] = [] - - for bf in track(selected, description="Cleaning up..."): - branch_decisions: dict[str, bool] = {} - wt_report: List[dict] = [] - - for wt in bf.worktrees: - delete_branch = False - if branch_pref == "Delete all branches": - delete_branch = True - elif branch_pref == "Ask per branch": - key = f"{wt.parent_repo_path}:{wt.branch_name}" - if key not in branch_decisions: - branch_decisions[key] = inquirer.confirm( - message=f"Delete branch '{wt.branch_name}' from {wt.parent_repo_path.name}?", - default=True, - ).execute() - delete_branch = branch_decisions[key] - - branch_deleted = _remove_worktree(wt, delete_branch) - wt_report.append({ - "repo": wt.parent_repo_path.name if wt.parent_repo_path else "unknown", - "branch": wt.branch_name, - "branch_deleted": branch_deleted, - }) - - if wt.parent_repo_path and wt.parent_repo_path.exists(): - parent_repos_seen.add(wt.parent_repo_path) - - # Remove leftover directories - if bf.path.exists(): - shutil.rmtree(bf.path, ignore_errors=True) - - removed_report.append({"workspace": bf.name, "worktrees": wt_report}) - - # Prune all affected parent repos - for repo in parent_repos_seen: - subprocess.run(["git", "worktree", "prune"], cwd=repo, capture_output=True) - - console.print("\n[bold green]Done![/bold green]") - - if json_output: - emit_json({"removed": removed_report}) diff --git a/src/spawnpoint/cli.py b/src/spawnpoint/cli.py deleted file mode 100644 index 435dd68..0000000 --- a/src/spawnpoint/cli.py +++ /dev/null @@ -1,500 +0,0 @@ -import atexit -import shutil -import subprocess -import sys -from pathlib import Path - -import typer -from InquirerPy import inquirer -from rich.console import Console - -from . import __version__ -from .config import ( - CONFIG_PATH, - SPAWNPOINT_DIR, - Config, - config_exists, - detect_scan_dirs, - load_config, - save_config, -) -# detect_scan_dirs/save_config used by _ensure_config for non-interactive setup - -app = typer.Typer( - name="spawnpoint", - help="Spawn multi-repo worktree workspaces for feature development.", - no_args_is_help=True, -) -console = Console() - - -def _ensure_config() -> Config: - """Load config, triggering init if none exists. - - In non-interactive mode (--no-input/--json), skip the interactive setup and - write a config from detected defaults so agents/scripts never hang on a prompt. - """ - if not config_exists(): - if {"--no-input", "-n", "--json"}.intersection(sys.argv[1:]): - cfg = Config() - cfg.scan_dirs = detect_scan_dirs() - save_config(cfg) - return cfg - console.print("[bold]Welcome to Spawnpoint![/bold] Let's set things up.\n") - _run_init() - return load_config() - - -def _run_init(): - """Interactive first-run setup.""" - cfg = Config() - - # Detect scan dirs - detected = detect_scan_dirs() - if detected: - console.print("Found these code directories:") - for d in detected: - console.print(f" [green]✓[/green] {d}") - console.print() - - use_detected = inquirer.confirm( - message="Use these as scan directories?", - default=True, - ).execute() - - if use_detected: - cfg.scan_dirs = detected - else: - cfg.scan_dirs = [] - - extras = inquirer.text( - message="Add any other directories? (comma-separated, enter to skip)", - ).execute() - - if extras and extras.strip(): - for d in extras.split(","): - d = d.strip() - if d: - p = Path(d).expanduser().resolve() - if p.is_dir(): - if p not in cfg.scan_dirs: - cfg.scan_dirs.append(p) - else: - console.print(f" [yellow]Skipping {d} (does not exist)[/yellow]") - - if not cfg.scan_dirs: - console.print("[yellow]No scan directories set. You can add them later in the config.[/yellow]") - - # Worktree dir - default_wt = str(SPAWNPOINT_DIR / "workspaces") - wt_dir = inquirer.text( - message="Where should workspaces be created?", - default=default_wt, - ).execute() - cfg.worktree_dir = Path(wt_dir).expanduser().resolve() - - # Auto install deps - cfg.auto_install_deps = inquirer.confirm( - message="Auto-install dependencies after creating worktrees?", - default=True, - ).execute() - - path = save_config(cfg) - console.print(f"\n[green]Config saved to {path}[/green]") - console.print(f"Run [bold]spawnpoint config[/bold] to view or edit later.\n") - - _offer_shell_integration() - - -_CD_PATH_FILE = "$HOME/.spawnpoint/.cd_path" - -_POSIX_SNIPPET = f""" -# spawnpoint shell integration -sp() {{ - local cmd="${{1:-create}}" - shift 2>/dev/null - local cd_file="{_CD_PATH_FILE}" - rm -f "$cd_file" - case "$cmd" in - create) spawnpoint create "$@" ;; - list|ls) spawnpoint list --cd "$@" ;; - *) spawnpoint "$cmd" "$@" ;; - esac - if [ -f "$cd_file" ]; then - local dir=$(cat "$cd_file") - rm -f "$cd_file" - [ -n "$dir" ] && cd "$dir" - fi -}} -""" -_FISH_SNIPPET = f""" -# spawnpoint shell integration -function sp - set cmd (test (count $argv) -gt 0; and echo $argv[1]; or echo create) - set rest $argv[2..] - set cd_file "{_CD_PATH_FILE}" - rm -f $cd_file - switch $cmd - case create - spawnpoint create $rest - case list ls - spawnpoint list --cd $rest - case '*' - spawnpoint $cmd $rest - end - if test -f $cd_file - set dir (cat $cd_file) - rm -f $cd_file - test -n "$dir"; and cd $dir - end -end -""" - - -def _detect_shell_rc() -> list[Path]: - """Return existing shell rc files, in preference order.""" - import os - - candidates = [] - shell = os.environ.get("SHELL", "") - home = Path.home() - - # Prefer the active shell's rc first - if "zsh" in shell: - candidates = [home / ".zshrc", home / ".bashrc", home / ".bash_profile"] - elif "bash" in shell: - candidates = [home / ".bashrc", home / ".bash_profile", home / ".zshrc"] - elif "fish" in shell: - candidates = [home / ".config" / "fish" / "config.fish"] - else: - candidates = [home / ".zshrc", home / ".bashrc", home / ".bash_profile"] - - return [p for p in candidates if p.exists()] - - -def _offer_shell_integration(): - """Offer to add the sp() shell function to the user's rc file.""" - rc_files = _detect_shell_rc() - - if not rc_files: - console.print("[dim]Tip: add this to your shell rc to auto-cd after creating a workspace:[/dim]") - console.print(f"[bold]{_POSIX_SNIPPET.strip()}[/bold]") - return - - # Check if already installed in any of them - for rc in rc_files: - if "spawnpoint shell integration" in rc.read_text(): - console.print(f"[dim]Shell integration already present in {rc}[/dim]") - return - - choices = [str(rc) for rc in rc_files] + ["Skip"] - target = inquirer.select( - message="Add sp() shell function for auto-cd after create?", - choices=choices, - default=str(rc_files[0]), - ).execute() - - if target == "Skip": - console.print("[dim]Skipped. You can add it manually:[/dim]") - console.print(f"[bold]{_POSIX_SNIPPET.strip()}[/bold]") - return - - rc_path = Path(target) - snippet = _FISH_SNIPPET if "fish" in rc_path.parts or rc_path.suffix == ".fish" else _POSIX_SNIPPET - with rc_path.open("a") as f: - f.write(snippet) - console.print(f"[green]Added sp() to {rc_path}[/green]") - console.print(f"[dim]Restart your shell or run: source {rc_path}[/dim]") - - -@app.command() -def create( - yes: bool = typer.Option(False, "--yes", "-y", help="Auto-select default base branch when creating new branches"), - no_input: bool = typer.Option(False, "--no-input", "-n", help="Non-interactive mode for agents/scripts (requires flags below)"), - repos: str = typer.Option(None, "--repos", help="Comma-separated repo names to select (non-interactive)"), - branch: str = typer.Option(None, "--branch", "-b", help="Branch name (non-interactive)"), - base: str = typer.Option(None, "--base", help="Base branch for new branches (defaults to detected default)"), - template: str = typer.Option(None, "--template", "-t", help="Start from a saved template's repo set"), - save_template: str = typer.Option(None, "--save-template", help="Save the selected repos as a template under this name"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Select repos and spawn worktree workspaces for a feature branch.""" - from .create import run_create - cfg = _ensure_config() - run_create( - cfg, - yes=yes, - no_input=no_input, - repos_arg=repos, - branch=branch, - base=base, - template=template, - save_template=save_template, - json_output=json_output, - ) - - -@app.command() -def add( - no_input: bool = typer.Option(False, "--no-input", "-n", help="Non-interactive mode for agents/scripts"), - repos: str = typer.Option(None, "--repos", help="Comma-separated repo names to add (non-interactive)"), - base: str = typer.Option(None, "--base", help="Base branch for new branches (defaults to detected default)"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Add repos to an existing workspace (run from inside a workspace).""" - from .add import run_add - cfg = _ensure_config() - run_add(cfg, no_input=no_input, repos_arg=repos, base=base, json_output=json_output) - - -@app.command(name="list") -def list_cmd( - cd: bool = typer.Option(False, "--cd", "-c", help="Interactively select a workspace to cd into"), - no_input: bool = typer.Option(False, "--no-input", "-n", help="Non-interactive mode (with --cd, requires --workspace)"), - workspace: str = typer.Option(None, "--workspace", help="Workspace name to cd into (non-interactive --cd)"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """List all worktree workspaces.""" - from .list import run_list - cfg = _ensure_config() - run_list(cfg, cd=cd, no_input=no_input, workspace=workspace, json_output=json_output) - - -@app.command() -def repos( - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """List repositories available to select (feeds create/add --repos).""" - from rich.table import Table - - from .utils import find_git_repos, make_display_path - cfg = _ensure_config() - valid_dirs = [d for d in cfg.scan_dirs if d.is_dir()] - if not valid_dirs: - console.print("[bold red]Error:[/bold red] No scan directories configured. Run [bold]spawnpoint init[/bold].") - raise typer.Exit(code=1) - - found = find_git_repos(valid_dirs, cfg.scan_depth) - if json_output: - from .io import emit_json - emit_json([{"name": make_display_path(r, valid_dirs), "path": str(r)} for r in found]) - return - - if not found: - console.print("[yellow]No git repositories found.[/yellow]") - return - - table = Table(show_header=True, header_style="bold") - table.add_column("Name") - table.add_column("Path") - for r in found: - table.add_row(make_display_path(r, valid_dirs), str(r)) - console.print(table) - - -template_app = typer.Typer( - name="template", - help="Manage reusable repo templates for spawning workspaces.", - no_args_is_help=True, -) -app.add_typer(template_app) - - -@template_app.command("list") -def template_list( - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """List saved templates.""" - from .templates import run_template_list - run_template_list(json_output=json_output) - - -@template_app.command("save") -def template_save( - name: str = typer.Argument(..., help="Template name, e.g. image-gen"), - repos: str = typer.Option(None, "--repos", help="Comma-separated repo names (skips the picker)"), - base: str = typer.Option(None, "--base", help="Default base branch for this template"), - description: str = typer.Option(None, "--description", "-d", help="Short description shown in listings"), - no_input: bool = typer.Option(False, "--no-input", "-n", help="Non-interactive mode for agents/scripts (requires --repos)"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Create or update a template. Omit --repos to pick repos interactively.""" - from .templates import run_template_save - cfg = _ensure_config() - run_template_save( - cfg, - name, - repos_arg=repos, - base=base, - description=description, - no_input=no_input, - json_output=json_output, - ) - - -@template_app.command("show") -def template_show( - name: str = typer.Argument(..., help="Template name"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Show a single template.""" - from .templates import run_template_show - run_template_show(name, json_output=json_output) - - -@template_app.command("delete") -def template_delete( - name: str = typer.Argument(..., help="Template name"), - no_input: bool = typer.Option(False, "--no-input", "-n", help="Skip the confirmation prompt"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Delete a template. Existing workspaces are untouched.""" - from .templates import run_template_delete - run_template_delete(name, no_input=no_input, json_output=json_output) - - -@app.command() -def cleanup( - no_input: bool = typer.Option(False, "--no-input", "-n", help="Non-interactive mode for agents/scripts"), - workspaces: str = typer.Option(None, "--workspaces", help="Comma-separated workspace names to remove (non-interactive)"), - delete_branches: bool = typer.Option(None, "--delete-branches/--keep-branches", help="Whether to delete branches (non-interactive)"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Select and remove worktree workspaces.""" - from .cleanup import run_cleanup - cfg = _ensure_config() - run_cleanup(cfg, no_input=no_input, workspaces=workspaces, delete_branches=delete_branches, json_output=json_output) - - -@app.command(name="light-cleanup") -def light_cleanup( - no_input: bool = typer.Option(False, "--no-input", "-n", help="Non-interactive mode for agents/scripts"), - workspaces: str = typer.Option(None, "--workspaces", help="Comma-separated workspace names (non-interactive)"), - artifact_types: str = typer.Option(None, "--artifact-types", help="Comma-separated artifact type names to delete, e.g. node_modules,.venv (non-interactive; default: all found)"), - json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON to stdout"), -): - """Delete reinstallable artifact dirs (node_modules, .venv, etc.) to free space without losing code.""" - from .light_cleanup import run_light_cleanup - cfg = _ensure_config() - run_light_cleanup(cfg, no_input=no_input, workspaces=workspaces, artifact_types=artifact_types, json_output=json_output) - - -@app.command() -def init(): - """Run interactive setup (creates or overwrites config).""" - if config_exists(): - overwrite = inquirer.confirm( - message=f"Config already exists at {CONFIG_PATH}. Overwrite?", - default=False, - ).execute() - if not overwrite: - console.print("Keeping existing config.") - _offer_shell_integration() - raise typer.Exit() - _run_init() - - -@app.command() -def config( - edit: bool = typer.Option(False, "--edit", "-e", help="Open config in $EDITOR"), - reset: bool = typer.Option(False, "--reset", help="Reset config to defaults"), -): - """View or edit configuration.""" - if reset: - confirm = inquirer.confirm( - message="Reset config to defaults?", - default=False, - ).execute() - if confirm: - cfg = Config() - cfg.scan_dirs = detect_scan_dirs() - save_config(cfg) - console.print("[green]Config reset to defaults.[/green]") - return - - if edit: - if not CONFIG_PATH.is_file(): - console.print("[yellow]No config file yet. Run [bold]spawnpoint init[/bold] first.[/yellow]") - raise typer.Exit(code=1) - editor = shutil.which("$EDITOR") or shutil.which("vim") or shutil.which("nano") - import os - editor = os.environ.get("EDITOR", editor or "vi") - subprocess.run([editor, str(CONFIG_PATH)]) - return - - if not CONFIG_PATH.is_file(): - console.print("[yellow]No config file. Run [bold]spawnpoint init[/bold] to create one.[/yellow]") - raise typer.Exit() - - console.print(f"[bold]Config:[/bold] {CONFIG_PATH}\n") - console.print(CONFIG_PATH.read_text()) - - -@app.command() -def update(): - """Update spawnpoint to the latest version.""" - if shutil.which("pipx"): - console.print("[blue]Updating via pipx...[/blue]") - result = subprocess.run(["pipx", "upgrade", "spawnpoint"], capture_output=True, text=True) - if result.returncode == 0: - console.print(f"[green]{result.stdout.strip()}[/green]") - else: - console.print(f"[red]{result.stderr.strip()}[/red]") - else: - console.print("[blue]Updating via pip...[/blue]") - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "spawnpoint"], - capture_output=True, text=True, - ) - if result.returncode == 0: - console.print("[green]Updated successfully.[/green]") - else: - console.print(f"[red]{result.stderr.strip()}[/red]") - - -def version_callback(value: bool): - if value: - console.print(f"spawnpoint {__version__}") - raise typer.Exit() - - -@app.callback() -def main( - version: bool = typer.Option( - False, "--version", "-v", - help="Show version and exit.", - callback=version_callback, - is_eager=True, - ), - debug: bool = typer.Option( - False, "--debug", - help="Enable debug logging.", - ), -): - """Spawnpoint — Spawn multi-repo worktree workspaces for feature development.""" - from .log import setup_logging - setup_logging(debug=debug) - - # Non-blocking update check (skip for update/version commands) - _args = sys.argv[1:] - _skip_commands = {"update", "--version", "-v"} - if not _skip_commands.intersection(_args): - from .config import load_config as _load_cfg - try: - cfg = _load_cfg() - if cfg.check_updates: - from .version_check import get_update_notice, start_check - start_check(__version__) - atexit.register(_show_update_notice) - except Exception: - pass - - -def _show_update_notice() -> None: - try: - from .version_check import get_update_notice - notice = get_update_notice() - if notice: - Console(stderr=True).print(notice) - except Exception: - pass diff --git a/src/spawnpoint/config.py b/src/spawnpoint/config.py deleted file mode 100644 index 805f77f..0000000 --- a/src/spawnpoint/config.py +++ /dev/null @@ -1,126 +0,0 @@ -import tomllib -from dataclasses import dataclass, field -from pathlib import Path -from typing import List - -SPAWNPOINT_DIR = Path.home() / ".spawnpoint" -CONFIG_PATH = SPAWNPOINT_DIR / "config.toml" -TEMPLATES_PATH = SPAWNPOINT_DIR / "templates.toml" -CD_PATH_FILE = SPAWNPOINT_DIR / ".cd_path" - -COMMON_CODE_DIRS = ["~/code", "~/projects", "~/repos", "~/src", "~/dev"] - -DEFAULT_COPY_PATTERNS_GLOBS = [".env*"] -DEFAULT_COPY_PATTERNS_FILES = ["AGENT.md", "CLAUDE.md", "GEMINI.md"] -DEFAULT_COPY_PATTERNS_DIRS = [".vscode", "docs"] - -@dataclass -class Config: - scan_dirs: List[Path] = field(default_factory=list) - worktree_dir: Path = field(default_factory=lambda: SPAWNPOINT_DIR / "workspaces") - scan_depth: int = 2 - copy_patterns_globs: List[str] = field(default_factory=lambda: list(DEFAULT_COPY_PATTERNS_GLOBS)) - copy_patterns_files: List[str] = field(default_factory=lambda: list(DEFAULT_COPY_PATTERNS_FILES)) - copy_patterns_dirs: List[str] = field(default_factory=lambda: list(DEFAULT_COPY_PATTERNS_DIRS)) - additional_worktree_dirs: List[Path] = field(default_factory=list) - auto_install_deps: bool = True - check_updates: bool = True - - -def expand_path(p: str) -> Path: - return Path(p).expanduser().resolve() - - -def detect_scan_dirs() -> List[Path]: - """Return common code directories that actually exist on this machine.""" - found = [] - for d in COMMON_CODE_DIRS: - p = Path(d).expanduser() - if p.is_dir(): - found.append(p) - return found - - -def config_exists() -> bool: - return CONFIG_PATH.is_file() - - -def load_config() -> Config: - """Load config from TOML, merging with defaults.""" - if not CONFIG_PATH.is_file(): - return Config() - - with open(CONFIG_PATH, "rb") as f: - data = tomllib.load(f) - - cfg = Config() - - if "scan_dirs" in data: - cfg.scan_dirs = [expand_path(d) for d in data["scan_dirs"]] - if "worktree_dir" in data: - cfg.worktree_dir = expand_path(data["worktree_dir"]) - if "scan_depth" in data: - cfg.scan_depth = int(data["scan_depth"]) - if "copy_patterns_globs" in data: - cfg.copy_patterns_globs = list(data["copy_patterns_globs"]) - if "copy_patterns_files" in data: - cfg.copy_patterns_files = list(data["copy_patterns_files"]) - if "copy_patterns_dirs" in data: - cfg.copy_patterns_dirs = list(data["copy_patterns_dirs"]) - if "additional_worktree_dirs" in data: - cfg.additional_worktree_dirs = [expand_path(d) for d in data["additional_worktree_dirs"]] - if "auto_install_deps" in data: - cfg.auto_install_deps = bool(data["auto_install_deps"]) - if "check_updates" in data: - cfg.check_updates = bool(data["check_updates"]) - - return cfg - - -def save_config(cfg: Config) -> Path: - """Write config to TOML file. Returns the config path.""" - SPAWNPOINT_DIR.mkdir(parents=True, exist_ok=True) - - def path_str(p: Path) -> str: - """Convert path to ~ notation for readability.""" - home = Path.home() - try: - return "~/" + str(p.relative_to(home)) - except ValueError: - return str(p) - - lines = [ - "# Spawnpoint configuration", - "# https://github.com/mihirgupta0900/spawnpoint", - "", - "# Directories to scan for git repos", - f"scan_dirs = [{', '.join(repr(path_str(d)) for d in cfg.scan_dirs)}]", - "", - "# Where workspaces are created", - f"worktree_dir = {repr(path_str(cfg.worktree_dir))}", - "", - "# Additional directories to scan during cleanup (for worktrees created at previous locations)", - f"additional_worktree_dirs = [{', '.join(repr(path_str(d)) for d in cfg.additional_worktree_dirs)}]", - "", - "# How deep to scan for repos (1-4)", - f"scan_depth = {cfg.scan_depth}", - "", - "# Glob patterns for files to copy into new worktrees", - f"copy_patterns_globs = {repr(cfg.copy_patterns_globs)}", - "", - "# Specific files to copy", - f"copy_patterns_files = {repr(cfg.copy_patterns_files)}", - "", - "# Directories to copy", - f"copy_patterns_dirs = {repr(cfg.copy_patterns_dirs)}", - "", - "# Auto-install dependencies after worktree creation", - f"auto_install_deps = {'true' if cfg.auto_install_deps else 'false'}", - "", - "# Check for new versions on startup", - f"check_updates = {'true' if cfg.check_updates else 'false'}", - "", - ] - - CONFIG_PATH.write_text("\n".join(lines)) - return CONFIG_PATH diff --git a/src/spawnpoint/create.py b/src/spawnpoint/create.py deleted file mode 100644 index 1962824..0000000 --- a/src/spawnpoint/create.py +++ /dev/null @@ -1,385 +0,0 @@ -import subprocess -import time -from pathlib import Path -from typing import Any, Dict, List - -import typer -from InquirerPy import inquirer -from rich.console import Console -from rich.progress import track - -from .config import CD_PATH_FILE, Config -from .io import emit_json, parse_csv, require, resolve_repos -from .log import logger -from .prompts import select_repos -from .templates import ( - get_template, - load_templates, - merge_template, - preselect_template_repos, - prompt_for_template, - template_repo_hint, - upsert_template, -) -from .utils import ( - copy_essential_files, - detect_default_branch, - find_git_repos, - make_display_path, - setup_dependencies, -) - -console = Console(stderr=True) - - -def _offer_save_template(selected_names: List[str], active_template) -> str | None: - """Offer to remember an interactively-picked repo set. Returns a name or None. - - Skipped for a single repo (a template buys you little there) and when the - selection still matches the template it came from. - """ - if len(selected_names) < 2: - return None - if active_template and set(selected_names) == set(active_template.repos): - return None - - if not inquirer.confirm( - message=f"Save these {len(selected_names)} repos as a template for next time?", - default=False, - ).execute(): - return None - - name = (inquirer.text(message="Template name:").execute() or "").strip() - if not name: - console.print("[dim]No name given, template not saved.[/dim]") - return None - - if name in load_templates() and not inquirer.confirm( - message=f"Template '{name}' already exists. Overwrite it?", - default=False, - ).execute(): - console.print("[dim]Template not saved.[/dim]") - return None - - return name - - -def run_create( - cfg: Config, - yes: bool = False, - *, - no_input: bool = False, - repos_arg: str | None = None, - branch: str | None = None, - base: str | None = None, - template: str | None = None, - save_template: str | None = None, - json_output: bool = False, -): - """Select git repos and create worktrees for a feature branch.""" - if not cfg.scan_dirs: - console.print("[bold red]Error:[/bold red] No scan directories configured.") - console.print("Run [bold]spawnpoint init[/bold] to set up.") - raise typer.Exit(code=1) - - # Validate a named template before the repo scan so typos fail fast. - active_template = get_template(template) if template else None - - # Validate scan dirs exist - valid_dirs = [d for d in cfg.scan_dirs if d.is_dir()] - if not valid_dirs: - console.print("[bold red]Error:[/bold red] None of your scan directories exist:") - for d in cfg.scan_dirs: - console.print(f" {d}") - raise typer.Exit(code=1) - - console.print("[bold blue]Scanning for git repositories...[/bold blue]") - for d in valid_dirs: - console.print(f" [dim]{d}[/dim]") - - start = time.monotonic() - repos = find_git_repos(valid_dirs, cfg.scan_depth) - logger.debug("Repo scan completed in %.2fs, found %d repos", time.monotonic() - start, len(repos)) - - if not repos: - console.print("[yellow]No git repositories found.[/yellow]") - raise typer.Exit() - - # Display paths relative to scan dirs for readability - choices = [make_display_path(repo, valid_dirs) for repo in repos] - choice_to_path = dict(zip(choices, repos)) - - # Offer saved templates as a starting point when nothing was passed by flag. - if active_template is None and not no_input and not repos_arg: - saved_templates = load_templates() - if saved_templates: - active_template = prompt_for_template(saved_templates) - - if active_template: - console.print(f"\n[bold blue]Template:[/bold blue] {active_template.name}") - if repos_arg: - console.print(" [dim]--repos overrides its repo list[/dim]") - else: - console.print(f" [dim]repos: {', '.join(active_template.repos)}[/dim]") - if base is None and active_template.base: - base = active_template.base - console.print(f" [dim]base branch: {base}[/dim]") - - if no_input: - if not repos_arg and not active_template: - console.print("[bold red]Error:[/bold red] --no-input requires --repos or --template.") - raise typer.Exit(code=1) - if repos_arg: - # An explicit --repos wins over the template's repo list. - selected_repos = resolve_repos(parse_csv(repos_arg), choice_to_path, err=console) - else: - selected_repos = resolve_repos( - active_template.repos, - choice_to_path, - err=console, - hint=template_repo_hint(active_template.name), - ) - if not selected_repos: - console.print("[bold red]Error:[/bold red] No repositories to spawn.") - raise typer.Exit(code=1) - else: - preselected = ( - preselect_template_repos(active_template, choice_to_path) if active_template else [] - ) - selected_labels = select_repos( - "Select repositories (type to search):", - choices, - preselected=preselected, - ) - - if not selected_labels: - console.print("No repositories selected. Exiting.") - raise typer.Exit() - - selected_repos = [choice_to_path[label] for label in selected_labels] - - if no_input: - branch_name = require(branch, "--branch", console) - else: - branch_name = inquirer.text(message="Enter the branch name:").execute() - if not branch_name: - console.print("Branch name cannot be empty.") - raise typer.Exit(code=1) - - # Phase 1: Fetch & prune - console.print(f"\n[bold blue]Preparing repositories...[/bold blue]") - for repo_path in track(selected_repos, description="Fetching & pruning..."): - try: - subprocess.run(["git", "fetch"], cwd=repo_path, capture_output=True) - subprocess.run( - ["git", "remote", "set-head", "origin", "--auto"], - cwd=repo_path, capture_output=True, - ) - subprocess.run(["git", "worktree", "prune"], cwd=repo_path, capture_output=True) - except Exception as e: - console.print(f"[yellow]Warning fetching {repo_path.name}: {e}[/yellow]") - - # Phase 2: Configure worktree actions - repo_actions: List[Dict[str, Any]] = [] - - console.print(f"\n[bold blue]Configuring worktrees...[/bold blue]") - - cfg.worktree_dir.mkdir(parents=True, exist_ok=True) - normalized_branch_dir = branch_name.replace("/", "-") - is_single_repo = len(selected_repos) == 1 - - for repo_path in selected_repos: - repo_name = repo_path.name - - if is_single_repo: - target_path = (cfg.worktree_dir / normalized_branch_dir).resolve() - else: - target_path = (cfg.worktree_dir / normalized_branch_dir / repo_name).resolve() - - if target_path.exists(): - console.print(f"[yellow]Skipping {repo_name}: {target_path} already exists.[/yellow]") - continue - - # Check if branch exists - local_exists = subprocess.run( - ["git", "rev-parse", "--verify", branch_name], - cwd=repo_path, capture_output=True, - ).returncode == 0 - - remote_exists = False - if not local_exists: - remote_exists = subprocess.run( - ["git", "rev-parse", "--verify", f"origin/{branch_name}"], - cwd=repo_path, capture_output=True, - ).returncode == 0 - - if local_exists or remote_exists: - repo_actions.append({ - "type": "add", - "repo_path": repo_path, - "target_path": target_path, - "repo_name": repo_name, - "branch": branch_name, - }) - else: - # Branch needs creation — detect default branch - detected = detect_default_branch(repo_path) - if base: - # An explicit --base (or a template's base) applies to every repo. - base_branch = base - console.print(f" [dim]{repo_name}: creating from {base_branch}[/dim]") - elif no_input: - base_branch = detected - if not base_branch: - console.print( - f"[bold red]Error:[/bold red] [{repo_name}] branch '{branch_name}' " - f"not found and no default branch detected. Pass --base." - ) - raise typer.Exit(code=1) - console.print(f" [dim]{repo_name}: creating from {base_branch}[/dim]") - elif yes and detected: - base_branch = detected - console.print(f" [dim]{repo_name}: creating from {detected}[/dim]") - elif detected: - choices_list = [detected, "Other (manual input)"] - base_branch = inquirer.select( - message=f"[{repo_name}] Branch '{branch_name}' not found. Create from:", - choices=choices_list, - default=detected, - ).execute() - if base_branch == "Other (manual input)": - base_branch = inquirer.text(message=f"[{repo_name}] Enter base branch:").execute() - else: - base_branch = inquirer.text( - message=f"[{repo_name}] Branch '{branch_name}' not found. Enter base branch:", - ).execute() - - repo_actions.append({ - "type": "create", - "repo_path": repo_path, - "target_path": target_path, - "repo_name": repo_name, - "branch": branch_name, - "base": base_branch, - }) - - if not repo_actions: - console.print("[yellow]No actions to perform. Exiting.[/yellow]") - raise typer.Exit() - - # Confirm - console.print(f"\n[bold]Plan:[/bold]") - for action in repo_actions: - if action["type"] == "add": - console.print(f" {action['repo_name']}: [green]add worktree[/green] for '{action['branch']}'") - else: - console.print(f" {action['repo_name']}: [blue]create branch[/blue] '{action['branch']}' from '{action['base']}'") - - if not no_input and not inquirer.confirm(message="Proceed?", default=True).execute(): - console.print("Aborted.") - raise typer.Exit() - - # Remember this repo set for next time. - selected_names = [make_display_path(p, valid_dirs) for p in selected_repos] - if not save_template and not no_input: - save_template = _offer_save_template(selected_names, active_template) - - if save_template: - saved_path, existed = upsert_template( - merge_template(save_template, selected_names, base=base) - ) - console.print( - f"[green]{'Updated' if existed else 'Saved'} template " - f"'{save_template}'[/green] [dim]({saved_path})[/dim]" - ) - console.print(f"[dim]Reuse it with: spawnpoint create -t {save_template}[/dim]") - - # Phase 3: Execute - for action in track(repo_actions, description="Creating worktrees..."): - repo_name = action["repo_name"] - repo_path = action["repo_path"] - target_path = action["target_path"] - action["status"] = "failed" - - console.print(f"Processing [bold]{repo_name}[/bold]...") - - try: - target_path.parent.mkdir(parents=True, exist_ok=True) - success = False - - if action["type"] == "add": - cmd = ["git", "worktree", "add", str(target_path), action["branch"]] - result = subprocess.run(cmd, cwd=repo_path, text=True, capture_output=True) - if result.returncode == 0: - success = True - console.print(f" [green]Worktree created.[/green]") - else: - console.print(f" [red]Failed:[/red] {result.stderr.strip()}") - - elif action["type"] == "create": - # Resolve to remote tip if available (fetch already ran in Phase 1) - start_point = action["base"] - check = subprocess.run( - ["git", "rev-parse", "--verify", f"origin/{action['base']}"], - cwd=repo_path, capture_output=True, - ) - if check.returncode == 0: - start_point = f"origin/{action['base']}" - - cmd = ["git", "worktree", "add", "--no-track", "-b", action["branch"], str(target_path), start_point] - result = subprocess.run(cmd, cwd=repo_path, text=True, capture_output=True) - if result.returncode == 0: - success = True - console.print(f" [green]Created from {action['base']}.[/green]") - else: - console.print(f" [red]Failed:[/red] {result.stderr.strip()}") - - if success: - action["status"] = "created" - # Init submodules - console.print(f" [dim]Initializing submodules...[/dim]") - subprocess.run( - ["git", "submodule", "update", "--init", "--recursive"], - cwd=target_path, capture_output=True, - ) - - copy_essential_files(repo_path, target_path, cfg) - - if cfg.auto_install_deps: - setup_dependencies(target_path) - - except Exception as e: - console.print(f"[red]Error processing {repo_name}: {e}[/red]") - - console.print("\n[bold green]Done![/bold green]") - - if is_single_repo and repo_actions: - workspace_path = repo_actions[0]['target_path'] - else: - workspace_path = (cfg.worktree_dir / normalized_branch_dir).resolve() - - console.print(f"Workspace: [bold blue]{workspace_path}[/bold blue]") - CD_PATH_FILE.write_text(str(workspace_path)) - - if json_output: - emit_json({ - "workspace": str(workspace_path), - "branch": branch_name, - "template": active_template.name if active_template else None, - "repos": [ - { - "name": a["repo_name"], - "branch": a["branch"], - "base": a.get("base"), - "action": a["type"], - "status": a.get("status", "failed"), - } - for a in repo_actions - ], - }) - - elif no_input: - # Plain stdout so agents can capture the workspace path via $(...). - # Skipped when --json is set so stdout stays valid JSON. - from .io import stdout_console - stdout_console.print(str(workspace_path)) diff --git a/src/spawnpoint/io.py b/src/spawnpoint/io.py deleted file mode 100644 index 5d8d8fa..0000000 --- a/src/spawnpoint/io.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Helpers for non-interactive (agent-friendly) command execution. - -Each command threads a ``no_input`` flag through its prompt sites. When set, -these helpers satisfy what would otherwise be an interactive prompt from flags, -and fail fast (non-zero exit, clear message) instead of hanging on a TTY. -""" - -import json as _json -from pathlib import Path -from typing import Dict, List, Optional - -import typer -from rich.console import Console - -# Plain stdout console for machine-readable / capturable output. -stdout_console = Console() - - -def parse_csv(value: Optional[str]) -> List[str]: - """Split a comma-separated flag value into trimmed, non-empty parts.""" - if not value: - return [] - return [part.strip() for part in value.split(",") if part.strip()] - - -def require(value: Optional[str], flag: str, err: Console) -> str: - """Return ``value`` or exit with an error naming the missing flag.""" - if value is None or (isinstance(value, str) and not value.strip()): - err.print(f"[bold red]Error:[/bold red] --no-input requires {flag}.") - raise typer.Exit(code=1) - return value - - -def resolve_names( - requested: List[str], - name_to_value: Dict[str, object], - *, - kind: str, - err: Console, - aliases: Optional[Dict[str, List[str]]] = None, - hint: Optional[str] = None, -) -> List[object]: - """Resolve requested names against available choices. - - ``name_to_value`` maps the canonical display name to its value. ``aliases`` - optionally maps an alternate name (e.g. a bare repo dir name) to the list of - canonical names it could refer to — used to detect ambiguity. ``hint`` is an - extra line printed on failure, e.g. naming the template the names came from. - - Exits non-zero on any unknown name or ambiguous bare name, listing the valid - choices so an agent can correct its call. - """ - resolved: List[object] = [] - for name in requested: - if name in name_to_value: - resolved.append(name_to_value[name]) - continue - if aliases and name in aliases: - matches = aliases[name] - if len(matches) > 1: - err.print( - f"[bold red]Error:[/bold red] {kind} '{name}' is ambiguous; " - f"matches: {', '.join(matches)}. Use the full name." - ) - if hint: - err.print(hint) - raise typer.Exit(code=1) - resolved.append(name_to_value[matches[0]]) - continue - err.print( - f"[bold red]Error:[/bold red] {kind} '{name}' not found. " - f"Valid: {', '.join(sorted(name_to_value)) or '(none)'}" - ) - if hint: - err.print(hint) - raise typer.Exit(code=1) - return resolved - - -def resolve_repos( - requested: List[str], - choice_to_path: Dict[str, Path], - *, - err: Console, - hint: Optional[str] = None, -) -> List[Path]: - """Resolve repo names against available repos by display label or bare dir name.""" - aliases: Dict[str, List[str]] = {} - for label, path in choice_to_path.items(): - aliases.setdefault(path.name, []).append(label) - return resolve_names( - requested, - dict(choice_to_path), - kind="repo", - err=err, - aliases=aliases, - hint=hint, - ) - - -def emit_json(payload: object) -> None: - """Print a JSON payload to stdout for machine consumption.""" - stdout_console.print_json(_json.dumps(payload, default=str)) diff --git a/src/spawnpoint/light_cleanup.py b/src/spawnpoint/light_cleanup.py deleted file mode 100644 index a8d6086..0000000 --- a/src/spawnpoint/light_cleanup.py +++ /dev/null @@ -1,282 +0,0 @@ -import os -import shutil -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass, field -from pathlib import Path -from typing import List, Optional - -import typer -from InquirerPy import inquirer -from rich.console import Console - -from .cleanup import BranchFolder, _scan_work_dir -from .config import Config -from .io import emit_json, parse_csv, resolve_names -from .log import logger - -console = Console() - -# Directories that are safe to delete and can be reinstalled -ARTIFACT_TARGETS = [ - ("node_modules", "Node.js dependencies"), - (".venv", "Python virtualenv (.venv)"), - ("venv", "Python virtualenv (venv)"), - ("env", "Python virtualenv (env)"), - (".tox", "Tox test environments"), - ("__pycache__", "Python bytecode cache"), - (".pytest_cache", "Pytest cache"), - ("dist", "Build output (dist)"), - ("build", "Build output (build)"), - (".next", "Next.js build cache"), - (".nuxt", "Nuxt.js build cache"), - (".turbo", "Turborepo cache"), - ("target", "Rust/Java build artifacts (target)"), - (".gradle", "Gradle cache"), - (".parcel-cache", "Parcel build cache"), - (".cache", "Generic cache directory"), -] - -ARTIFACT_NAMES = {name for name, _ in ARTIFACT_TARGETS} - - -def _dir_size_bytes(path: Path) -> int: - total = 0 - try: - for dirpath, _dirs, files in os.walk(path): - for f in files: - try: - total += os.path.getsize(os.path.join(dirpath, f)) - except OSError: - pass - except OSError: - pass - return total - - -def _fmt_bytes(n: int) -> str: - for unit in ("B", "KB", "MB", "GB"): - if n < 1024: - return f"{n:.1f} {unit}" - n /= 1024 - return f"{n:.1f} TB" - - -@dataclass -class ArtifactDir: - path: Path - artifact_name: str - size_bytes: int = 0 - - -@dataclass -class WorkspaceArtifacts: - folder: BranchFolder - artifacts: List[ArtifactDir] = field(default_factory=list) - - @property - def total_size(self) -> int: - return sum(a.size_bytes for a in self.artifacts) - - @property - def artifact_names(self) -> set[str]: - return {a.artifact_name for a in self.artifacts} - - -def _find_artifacts_in_path(root: Path) -> List[ArtifactDir]: - """Find all reinstallable artifact dirs under root (non-recursive past first hit).""" - found: List[ArtifactDir] = [] - _walk(root, found) - return found - - -def _walk(path: Path, found: List[ArtifactDir], depth: int = 0): - if depth > 6: - return - try: - for child in path.iterdir(): - if not child.is_dir() or child.is_symlink(): - continue - if child.name in ARTIFACT_NAMES: - found.append(ArtifactDir(path=child, artifact_name=child.name)) - # don't recurse into artifact dirs - else: - _walk(child, found, depth + 1) - except PermissionError: - pass - - -def _scan_workspace_artifacts(folder: BranchFolder) -> WorkspaceArtifacts: - artifacts: List[ArtifactDir] = [] - for wt in folder.worktrees: - artifacts.extend(_find_artifacts_in_path(wt.worktree_path)) - ws = WorkspaceArtifacts(folder=folder, artifacts=artifacts) - return ws - - -def _compute_sizes(ws: WorkspaceArtifacts) -> None: - with ThreadPoolExecutor(max_workers=8) as pool: - futures = {pool.submit(_dir_size_bytes, a.path): a for a in ws.artifacts} - for fut, art in futures.items(): - art.size_bytes = fut.result() - - -def run_light_cleanup( - cfg: Config, - *, - no_input: bool = False, - workspaces: str | None = None, - artifact_types: str | None = None, - json_output: bool = False, -): - """Delete reinstallable artifact directories (node_modules, .venv, etc.) from workspaces.""" - work_dirs = [cfg.worktree_dir] + [ - d for d in cfg.additional_worktree_dirs if d != cfg.worktree_dir - ] - existing_dirs = [d for d in work_dirs if d.exists()] - - if not existing_dirs: - console.print("[yellow]No workspaces directory found.[/yellow]") - raise typer.Exit() - - console.print("[bold blue]Scanning for worktree workspaces...[/bold blue]") - - folders: list[BranchFolder] = [] - seen_paths: set[Path] = set() - for work_dir in existing_dirs: - for bf in _scan_work_dir(work_dir): - resolved = bf.path.resolve() - if resolved not in seen_paths: - seen_paths.add(resolved) - folders.append(bf) - - if not folders: - console.print("[yellow]No worktree workspaces found.[/yellow]") - raise typer.Exit() - - folders.sort(key=lambda bf: bf.oldest_modified) - - # --- Select workspaces --- - if no_input: - if not workspaces: - console.print("[bold red]Error:[/bold red] --no-input requires --workspaces.") - raise typer.Exit(code=1) - name_to_bf = {bf.name: bf for bf in folders} - requested = parse_csv(workspaces) - selected_folders = resolve_names(requested, name_to_bf, kind="workspace", err=console) - else: - folder_choices = [bf.name for bf in folders] - selected_names = inquirer.fuzzy( - message="Select workspaces for light cleanup (type to search):", - choices=folder_choices, - multiselect=True, - ).execute() - - if not selected_names: - console.print("No workspaces selected. Exiting.") - raise typer.Exit() - - name_to_bf = {bf.name: bf for bf in folders} - selected_folders = [name_to_bf[n] for n in selected_names] - - # --- Scan artifacts --- - console.print("[dim]Scanning for artifact directories...[/dim]") - workspace_artifacts: list[WorkspaceArtifacts] = [] - for bf in selected_folders: - ws = _scan_workspace_artifacts(bf) - if ws.artifacts: - workspace_artifacts.append(ws) - - if not workspace_artifacts: - console.print("[green]No reinstallable artifact directories found.[/green]") - raise typer.Exit() - - # Compute sizes in parallel per workspace - console.print("[dim]Calculating sizes...[/dim]") - with ThreadPoolExecutor(max_workers=4) as pool: - list(pool.map(_compute_sizes, workspace_artifacts)) - - # --- Select artifact types to delete --- - all_found_types: set[str] = set() - for ws in workspace_artifacts: - all_found_types |= ws.artifact_names - - if no_input: - if artifact_types: - requested_types = set(parse_csv(artifact_types)) - else: - # default: all found types - requested_types = all_found_types - selected_types = all_found_types & requested_types - else: - type_choices = [] - for name, desc in ARTIFACT_TARGETS: - if name not in all_found_types: - continue - total = sum( - a.size_bytes - for ws in workspace_artifacts - for a in ws.artifacts - if a.artifact_name == name - ) - type_choices.append(f"{name} ({desc}) [{_fmt_bytes(total)}]") - - selected_type_labels = inquirer.checkbox( - message="Which artifact types to delete?", - choices=type_choices, - default=type_choices, - ).execute() - - if not selected_type_labels: - console.print("Nothing selected. Exiting.") - raise typer.Exit() - - selected_types = {label.split()[0] for label in selected_type_labels} - - # Filter artifacts down to selected types - for ws in workspace_artifacts: - ws.artifacts = [a for a in ws.artifacts if a.artifact_name in selected_types] - workspace_artifacts = [ws for ws in workspace_artifacts if ws.artifacts] - - if not workspace_artifacts: - console.print("[yellow]No matching artifacts after filtering.[/yellow]") - raise typer.Exit() - - # --- Show plan --- - total_bytes = sum(ws.total_size for ws in workspace_artifacts) - console.print(f"\n[bold]Plan[/bold] — will free ~{_fmt_bytes(total_bytes)}:\n") - for ws in workspace_artifacts: - console.print(f" [bold]{ws.folder.name}/[/bold] (~{_fmt_bytes(ws.total_size)})") - for art in sorted(ws.artifacts, key=lambda a: -a.size_bytes): - rel = art.path.relative_to(ws.folder.path) - console.print(f" [dim]{rel}[/dim] {_fmt_bytes(art.size_bytes)}") - console.print("") - - if not no_input and not inquirer.confirm( - message="Proceed with light cleanup?", - default=True, - ).execute(): - console.print("Aborted.") - raise typer.Exit() - - # --- Execute --- - deleted_report: list[dict] = [] - for ws in workspace_artifacts: - ws_report: list[dict] = [] - for art in ws.artifacts: - try: - shutil.rmtree(art.path) - logger.debug("Deleted %s", art.path) - ws_report.append({"path": str(art.path), "size_bytes": art.size_bytes, "ok": True}) - console.print(f" [green]✓[/green] {art.path.relative_to(ws.folder.path)} ({_fmt_bytes(art.size_bytes)})") - except Exception as exc: - console.print(f" [red]✗[/red] {art.path.relative_to(ws.folder.path)}: {exc}") - ws_report.append({"path": str(art.path), "size_bytes": art.size_bytes, "ok": False, "error": str(exc)}) - deleted_report.append({"workspace": ws.folder.name, "artifacts": ws_report}) - - freed = sum( - a["size_bytes"] for ws in deleted_report for a in ws["artifacts"] if a["ok"] - ) - console.print(f"\n[bold green]Done![/bold green] Freed ~{_fmt_bytes(freed)}.") - - if json_output: - emit_json({"freed_bytes": freed, "workspaces": deleted_report}) diff --git a/src/spawnpoint/list.py b/src/spawnpoint/list.py deleted file mode 100644 index 70c04a6..0000000 --- a/src/spawnpoint/list.py +++ /dev/null @@ -1,134 +0,0 @@ -import time -from pathlib import Path -from typing import List - -import typer -from InquirerPy import inquirer -from rich.console import Console -from rich.table import Table - -from .cleanup import BranchFolder, _format_age, _scan_work_dir -from .config import CD_PATH_FILE, Config -from .io import emit_json -from .log import logger - -console = Console(stderr=True) - - -def _collect_folders(cfg: Config) -> List[BranchFolder]: - """Scan all worktree dirs and return deduplicated folders.""" - work_dirs = [cfg.worktree_dir] + [ - d for d in cfg.additional_worktree_dirs if d != cfg.worktree_dir - ] - logger.debug("Worktree dirs to scan: %s", work_dirs) - - folders: List[BranchFolder] = [] - seen_paths: set[Path] = set() - for work_dir in work_dirs: - if not work_dir.exists(): - continue - for bf in _scan_work_dir(work_dir): - resolved = bf.path.resolve() - if resolved not in seen_paths: - seen_paths.add(resolved) - folders.append(bf) - - return folders - - -def _folder_payload(bf: BranchFolder) -> dict: - branches = sorted({w.branch_name for w in bf.worktrees}) - return { - "name": bf.name, - "path": str(bf.path), - "repos": len(bf.worktrees), - "branches": branches, - "dirty": bf.any_dirty, - } - - -def run_list( - cfg: Config, - cd: bool = False, - *, - no_input: bool = False, - workspace: str | None = None, - json_output: bool = False, -): - """List all worktree workspaces, optionally selecting one to cd into.""" - start = time.monotonic() - folders = _collect_folders(cfg) - logger.debug("Scan completed in %.2fs, found %d workspaces", time.monotonic() - start, len(folders)) - - if not folders: - if json_output: - emit_json([]) - return - console.print("[yellow]No workspaces found.[/yellow]") - raise typer.Exit() - - # Sort newest-first for listing - folders.sort(key=lambda bf: bf.oldest_modified, reverse=True) - - if json_output and not cd: - emit_json([_folder_payload(bf) for bf in folders]) - return - - if not cd: - # Just print a table - table = Table(show_header=True, header_style="bold") - table.add_column("Workspace") - table.add_column("Repos", justify="right") - table.add_column("Branch") - table.add_column("Status") - table.add_column("Last Modified") - - for bf in folders: - repo_count = str(len(bf.worktrees)) - branches = set(w.branch_name for w in bf.worktrees) - branch_str = ", ".join(sorted(branches)) - dirty_str = "[yellow]dirty[/yellow]" if bf.any_dirty else "[green]clean[/green]" - age = _format_age(bf.oldest_modified) - - table.add_row(bf.name, repo_count, branch_str, dirty_str, age) - - console.print(table) - return - - # Interactive selection for cd - folder_map = {} - for bf in folders: - repo_count = len(bf.worktrees) - dirty_str = "dirty" if bf.any_dirty else "clean" - age = _format_age(bf.oldest_modified) - repo_label = "repo" if repo_count == 1 else "repos" - label = f"{bf.name} ({repo_count} {repo_label}, {dirty_str}, {age})" - folder_map[label] = bf - - if no_input: - if not workspace: - console.print("[bold red]Error:[/bold red] --no-input requires --workspace.") - raise typer.Exit(code=1) - name_to_bf = {bf.name: bf for bf in folders} - if workspace not in name_to_bf: - console.print( - f"[bold red]Error:[/bold red] workspace '{workspace}' not found. " - f"Valid: {', '.join(sorted(name_to_bf)) or '(none)'}" - ) - raise typer.Exit(code=1) - bf = name_to_bf[workspace] - else: - selected_label = inquirer.fuzzy( - message="Select workspace:", - choices=list(folder_map.keys()), - ).execute() - - if not selected_label: - raise typer.Exit() - - bf = folder_map[selected_label] - - CD_PATH_FILE.write_text(str(bf.path)) - if no_input: - from .io import stdout_console - stdout_console.print(str(bf.path)) diff --git a/src/spawnpoint/log.py b/src/spawnpoint/log.py deleted file mode 100644 index c856ba4..0000000 --- a/src/spawnpoint/log.py +++ /dev/null @@ -1,12 +0,0 @@ -import logging -import sys - -logger = logging.getLogger("spawnpoint") - - -def setup_logging(debug: bool = False): - level = logging.DEBUG if debug else logging.WARNING - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) - logger.setLevel(level) - logger.addHandler(handler) diff --git a/src/spawnpoint/prompts.py b/src/spawnpoint/prompts.py deleted file mode 100644 index fe69612..0000000 --- a/src/spawnpoint/prompts.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Shared interactive prompts. - -The repo picker is used by ``create``, ``add`` and ``template save``, so it lives -here rather than being duplicated per command. -""" - -from typing import List, Sequence - -from InquirerPy.base.control import Choice -from InquirerPy.prompts.fuzzy import FuzzyPrompt - - -class ClearOnToggleFuzzyPrompt(FuzzyPrompt): - """FuzzyPrompt that clears search and shows selected repos on toggle.""" - - def _handle_toggle_choice(self, _) -> None: - super()._handle_toggle_choice(_) - self._buffer.reset() - # Reset filtered list to show all choices (not just previous search results) - for choice in self.content_control.choices: - choice["indices"] = [] - self.content_control._filtered_choices = self.content_control.choices - - def _generate_after_input(self): - display = super()._generate_after_input() - selected = self.selected_choices - if selected: - names = ", ".join(c["name"] for c in selected) - display.append(("", " ")) - display.append(("class:fuzzy_info", f"Selected: {names}")) - return display - - -def select_repos( - message: str, - labels: Sequence[str], - preselected: Sequence[str] = (), -) -> List[str]: - """Fuzzy multi-select over repo labels; returns the selected labels. - - ``preselected`` labels start toggled on, so a template can seed the picker - while still letting the user add or drop repos for this one workspace. - """ - enabled = set(preselected) - choices = [Choice(value=label, name=label, enabled=label in enabled) for label in labels] - return ClearOnToggleFuzzyPrompt( - message=message, - choices=choices, - multiselect=True, - ).execute() diff --git a/src/spawnpoint/templates.py b/src/spawnpoint/templates.py deleted file mode 100644 index 427a348..0000000 --- a/src/spawnpoint/templates.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Workspace templates — reusable named sets of repos. - -A template records the repos you keep spawning together (say "image-gen" = -chottu + backend + daily-prophet) plus an optional default base branch, so -recurring work starts from one flag instead of re-picking the same repos. - -Templates live in their own file (``~/.spawnpoint/templates.toml``) because the -CLI writes them, while ``config.toml`` stays a hand-edited settings file that -``init``/``config --reset`` regenerate. -""" - -import time -import tomllib -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional - -import typer -from InquirerPy import inquirer -from rich.console import Console -from rich.table import Table - -from .config import SPAWNPOINT_DIR, TEMPLATES_PATH, Config -from .io import emit_json, parse_csv, require, resolve_repos -from .log import logger -from .prompts import select_repos -from .utils import find_git_repos, make_display_path - -console = Console(stderr=True) - -# Detail line shown per template in listings; keeps long repo lists readable. -_MAX_DETAIL_LEN = 60 - - -@dataclass -class Template: - """A named set of repos to spawn together.""" - - name: str - repos: List[str] = field(default_factory=list) - base: Optional[str] = None - description: Optional[str] = None - - -# --- Storage ----------------------------------------------------------------- - -_TOML_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"} - - -def _toml_str(value: str) -> str: - """Quote a string as a TOML basic string.""" - out = [] - for ch in value: - if ch in _TOML_ESCAPES: - out.append(_TOML_ESCAPES[ch]) - elif ord(ch) < 0x20 or ord(ch) == 0x7F: - out.append(f"\\u{ord(ch):04X}") - else: - out.append(ch) - return '"' + "".join(out) + '"' - - -def load_templates() -> Dict[str, Template]: - """Load templates keyed by name. Returns an empty dict if none are saved.""" - if not TEMPLATES_PATH.is_file(): - return {} - - try: - with open(TEMPLATES_PATH, "rb") as f: - data = tomllib.load(f) - except tomllib.TOMLDecodeError as e: - console.print(f"[bold red]Error:[/bold red] {TEMPLATES_PATH} is not valid TOML: {e}") - console.print("Fix the file by hand, or delete it to start over.") - raise typer.Exit(code=1) - - templates: Dict[str, Template] = {} - for name, body in (data.get("templates") or {}).items(): - if not isinstance(body, dict): - logger.debug("Skipping malformed template entry: %s", name) - continue - templates[name] = Template( - name=name, - repos=[str(r) for r in body.get("repos", [])], - base=body.get("base") or None, - description=body.get("description") or None, - ) - return templates - - -def save_templates(templates: Dict[str, Template]) -> Path: - """Write all templates to TOML, sorted by name. Returns the file path.""" - SPAWNPOINT_DIR.mkdir(parents=True, exist_ok=True) - - lines = [ - "# Spawnpoint workspace templates", - "# Managed by `spawnpoint template save` and `spawnpoint template delete`.", - "# Repo names must match the names shown by `spawnpoint repos`.", - "", - ] - for name in sorted(templates): - template = templates[name] - lines.append(f"[templates.{_toml_str(name)}]") - if template.description: - lines.append(f"description = {_toml_str(template.description)}") - lines.append("repos = [" + ", ".join(_toml_str(r) for r in template.repos) + "]") - if template.base: - lines.append(f"base = {_toml_str(template.base)}") - lines.append("") - - TEMPLATES_PATH.write_text("\n".join(lines)) - return TEMPLATES_PATH - - -def get_template(name: str) -> Template: - """Return a template by name, or exit listing the valid names.""" - templates = load_templates() - if name in templates: - return templates[name] - - console.print( - f"[bold red]Error:[/bold red] template '{name}' not found. " - f"Valid: {', '.join(sorted(templates)) or '(none)'}" - ) - console.print("Create one with [bold]spawnpoint template save [/bold].") - raise typer.Exit(code=1) - - -def upsert_template(template: Template) -> tuple[Path, bool]: - """Save a template. Returns (path, existed_before).""" - templates = load_templates() - existed = template.name in templates - templates[template.name] = template - return save_templates(templates), existed - - -def merge_template( - name: str, - repos: List[str], - *, - base: Optional[str] = None, - description: Optional[str] = None, -) -> Template: - """Build a template, keeping the saved base/description when not overridden.""" - existing = load_templates().get(name) - return Template( - name=name, - repos=repos, - base=base or (existing.base if existing else None), - description=description or (existing.description if existing else None), - ) - - -def template_payload(template: Template) -> dict: - return { - "name": template.name, - "repos": template.repos, - "base": template.base, - "description": template.description, - } - - -# --- Shared helpers ---------------------------------------------------------- - - -def template_detail(template: Template) -> str: - """Short one-line summary of a template, for pickers and tables.""" - detail = template.description or ", ".join(template.repos) - if len(detail) > _MAX_DETAIL_LEN: - detail = detail[: _MAX_DETAIL_LEN - 3] + "..." - return detail - - -def template_repo_hint(name: str) -> str: - """Failure hint for repo names that came from a template rather than a flag.""" - return ( - f"Those repo names came from template '{name}'. Update it with " - f"[bold]spawnpoint template save {name} --repos ...[/bold]." - ) - - -def preselect_template_repos(template: Template, choice_to_path: Dict[str, Path]) -> List[str]: - """Map a template's repo names onto available picker labels. - - Leniently skips repos that no longer exist (with a warning) so a stale - template still gets you a pre-filled picker instead of an error. - """ - label_by_name: Dict[str, str] = {label: label for label in choice_to_path} - for label, path in choice_to_path.items(): - label_by_name.setdefault(path.name, label) - - labels: List[str] = [] - missing: List[str] = [] - for name in template.repos: - label = label_by_name.get(name) - if label is None: - missing.append(name) - elif label not in labels: - labels.append(label) - - if missing: - console.print( - f"[yellow]Template '{template.name}' lists repos that are no longer " - f"available: {', '.join(missing)}[/yellow]" - ) - return labels - - -def prompt_for_template(templates: Dict[str, Template]) -> Optional[Template]: - """Offer saved templates as a starting point. Returns None for manual picking.""" - manual = "Pick repos manually" - by_label = { - f"{templates[name].name} ({template_detail(templates[name])})": templates[name] - for name in sorted(templates) - } - selected = inquirer.select( - message="Start from a template?", - choices=[manual] + list(by_label), - default=manual, - ).execute() - return by_label.get(selected) - - -def _scan_repo_choices(cfg: Config) -> Dict[str, Path]: - """Scan configured dirs and return {display label: repo path}.""" - valid_dirs = [d for d in cfg.scan_dirs if d.is_dir()] - if not valid_dirs: - console.print("[bold red]Error:[/bold red] No valid scan directories configured.") - console.print("Run [bold]spawnpoint init[/bold] to set up.") - raise typer.Exit(code=1) - - start = time.monotonic() - repos = find_git_repos(valid_dirs, cfg.scan_depth) - logger.debug("Repo scan completed in %.2fs, found %d repos", time.monotonic() - start, len(repos)) - - if not repos: - console.print("[yellow]No git repositories found.[/yellow]") - raise typer.Exit(code=1) - - return {make_display_path(repo, valid_dirs): repo for repo in repos} - - -# --- Commands ---------------------------------------------------------------- - - -def run_template_list(*, json_output: bool = False): - """List saved templates.""" - templates = load_templates() - - if json_output: - emit_json([template_payload(templates[name]) for name in sorted(templates)]) - return - - if not templates: - console.print("[yellow]No templates saved yet.[/yellow]") - console.print( - "Create one with [bold]spawnpoint template save [/bold], or " - "[bold]spawnpoint create --save-template [/bold]." - ) - return - - table = Table(show_header=True, header_style="bold") - table.add_column("Template") - table.add_column("Repos") - table.add_column("Base") - table.add_column("Description") - - for name in sorted(templates): - template = templates[name] - table.add_row( - name, - ", ".join(template.repos) or "[dim]none[/dim]", - template.base or "[dim]auto[/dim]", - template.description or "", - ) - - console.print(table) - - -def run_template_show(name: str, *, json_output: bool = False): - """Show one template.""" - template = get_template(name) - - if json_output: - emit_json(template_payload(template)) - return - - console.print(f"[bold]{template.name}[/bold]") - if template.description: - console.print(f" {template.description}") - console.print(f" Repos: {', '.join(template.repos) or '(none)'}") - console.print(f" Base: {template.base or 'auto-detected per repo'}") - - -def run_template_save( - cfg: Config, - name: str, - *, - repos_arg: Optional[str] = None, - base: Optional[str] = None, - description: Optional[str] = None, - no_input: bool = False, - json_output: bool = False, -): - """Create or update a template, picking repos interactively unless --repos is given.""" - if not name.strip(): - console.print("[bold red]Error:[/bold red] Template name cannot be empty.") - raise typer.Exit(code=1) - - existing = load_templates().get(name) - choice_to_path = _scan_repo_choices(cfg) - - if no_input: - repos_arg = require(repos_arg, "--repos", console) - - if repos_arg: - selected = resolve_repos(parse_csv(repos_arg), choice_to_path, err=console) - else: - preselected = preselect_template_repos(existing, choice_to_path) if existing else [] - labels = select_repos( - f"Select repositories for template '{name}' (type to search):", - list(choice_to_path), - preselected=preselected, - ) - if not labels: - console.print("No repositories selected. Nothing saved.") - raise typer.Exit() - selected = [choice_to_path[label] for label in labels] - - if not selected: - console.print("[bold red]Error:[/bold red] A template needs at least one repo.") - raise typer.Exit(code=1) - - valid_dirs = [d for d in cfg.scan_dirs if d.is_dir()] - template = merge_template( - name, - [make_display_path(path, valid_dirs) for path in selected], - base=base, - description=description, - ) - path, existed = upsert_template(template) - - verb = "Updated" if existed else "Saved" - console.print(f"[green]{verb} template '{name}'[/green] ({', '.join(template.repos)})") - console.print(f"[dim]{path}[/dim]") - console.print(f"Use it with [bold]spawnpoint create --template {name}[/bold]") - - if json_output: - emit_json(template_payload(template)) - - -def run_template_delete(name: str, *, no_input: bool = False, json_output: bool = False): - """Delete a template.""" - template = get_template(name) - - if not no_input: - confirm = inquirer.confirm( - message=f"Delete template '{name}' ({', '.join(template.repos)})?", - default=False, - ).execute() - if not confirm: - console.print("Aborted.") - raise typer.Exit() - - templates = load_templates() - templates.pop(name, None) - save_templates(templates) - - console.print(f"[green]Deleted template '{name}'.[/green]") - console.print("[dim]Existing workspaces are untouched.[/dim]") - - if json_output: - emit_json({"deleted": name}) diff --git a/src/spawnpoint/utils.py b/src/spawnpoint/utils.py deleted file mode 100644 index 9442c74..0000000 --- a/src/spawnpoint/utils.py +++ /dev/null @@ -1,199 +0,0 @@ -import os -import re -import shutil -import subprocess -from pathlib import Path -from typing import List, Optional - -from rich.console import Console - -from .config import Config - -console = Console() - - -def is_git_repo(path: Path) -> bool: - return (path / ".git").exists() - - -def find_git_repos(scan_dirs: List[Path], max_depth: int = 2) -> List[Path]: - """Find all git repos under the given directories, up to max_depth levels deep.""" - git_repos = [] - - for root_dir in scan_dirs: - if not root_dir.is_dir(): - continue - root_dir = root_dir.resolve() - - for path in root_dir.glob("*"): - if path.is_dir(): - if is_git_repo(path): - git_repos.append(path) - elif max_depth > 1: - for subpath in path.glob("*"): - if subpath.is_dir() and is_git_repo(subpath): - git_repos.append(subpath) - - return sorted(set(git_repos)) - - -def copy_essential_files(source_dir: Path, target_dir: Path, cfg: Config): - """Copy configured files and directories from source to target worktree.""" - # Glob patterns (e.g. .env*) - for pattern in cfg.copy_patterns_globs: - for item in source_dir.glob(pattern): - if item.is_file(): - dest = target_dir / item.name - if not dest.exists(): - try: - shutil.copy2(item, dest) - console.print(f" [dim]Copied {item.name}[/dim]") - except Exception as e: - console.print(f" [red]Failed to copy {item.name}: {e}[/red]") - - # Specific files - for filename in cfg.copy_patterns_files: - src = source_dir / filename - dest = target_dir / filename - if src.exists() and src.is_file() and not dest.exists(): - try: - shutil.copy2(src, dest) - console.print(f" [dim]Copied {filename}[/dim]") - except Exception as e: - console.print(f" [red]Failed to copy {filename}: {e}[/red]") - - # Directories - for dirname in cfg.copy_patterns_dirs: - src = source_dir / dirname - dest = target_dir / dirname - if src.exists() and src.is_dir(): - try: - shutil.copytree(src, dest, dirs_exist_ok=True) - console.print(f" [dim]Copied {dirname}/[/dim]") - except Exception as e: - console.print(f" [red]Failed to copy {dirname}: {e}[/red]") - - -def setup_dependencies(target_dir: Path): - """Detect project type and install dependencies.""" - # Clean environment without CLI's VIRTUAL_ENV - clean_env = os.environ.copy() - clean_env.pop("VIRTUAL_ENV", None) - - try: - # Node.js - if (target_dir / "pnpm-lock.yaml").exists(): - console.print(" [blue]Detected pnpm. Installing...[/blue]") - subprocess.run(["pnpm", "install"], cwd=target_dir, env=clean_env, check=True) - elif (target_dir / "bun.lockb").exists() or (target_dir / "bun.lock").exists(): - console.print(" [blue]Detected bun. Installing...[/blue]") - subprocess.run(["bun", "install"], cwd=target_dir, env=clean_env, check=True) - elif (target_dir / "yarn.lock").exists(): - console.print(" [blue]Detected yarn. Installing...[/blue]") - subprocess.run(["yarn", "install"], cwd=target_dir, env=clean_env, check=True) - elif (target_dir / "package.json").exists(): - console.print(" [blue]Detected package.json. Running npm install...[/blue]") - subprocess.run(["npm", "install"], cwd=target_dir, env=clean_env, check=True) - - # Go - if (target_dir / "go.mod").exists(): - console.print(" [blue]Detected go.mod. Downloading modules...[/blue]") - subprocess.run(["go", "mod", "download"], cwd=target_dir, env=clean_env, check=True) - - # Ruby - if (target_dir / "Gemfile").exists(): - console.print(" [blue]Detected Gemfile. Installing gems...[/blue]") - subprocess.run(["bundle", "install"], cwd=target_dir, env=clean_env, check=True) - - # Python - has_uv = shutil.which("uv") is not None - - if (target_dir / "uv.lock").exists() and has_uv: - console.print(" [blue]Detected uv.lock. Syncing...[/blue]") - subprocess.run(["uv", "sync"], cwd=target_dir, env=clean_env, check=True) - elif (target_dir / "poetry.lock").exists(): - console.print(" [blue]Detected poetry. Installing...[/blue]") - subprocess.run(["poetry", "install"], cwd=target_dir, env=clean_env, check=True) - elif (target_dir / "requirements.txt").exists(): - console.print(" [blue]Detected requirements.txt.[/blue]") - if has_uv: - console.print(" [blue]Using uv to create venv and install...[/blue]") - subprocess.run(["uv", "venv"], cwd=target_dir, env=clean_env, check=True) - subprocess.run( - ["uv", "pip", "install", "-r", "requirements.txt"], - cwd=target_dir, env=clean_env, check=True, - ) - else: - console.print(" [blue]Creating venv and installing...[/blue]") - subprocess.run(["python3", "-m", "venv", ".venv"], cwd=target_dir, env=clean_env, check=True) - pip_path = target_dir / ".venv" / "bin" / "pip" - subprocess.run( - [str(pip_path), "install", "-r", "requirements.txt"], - cwd=target_dir, env=clean_env, check=True, - ) - - except subprocess.CalledProcessError as e: - console.print(f" [red]Dependency installation failed:[/red] {e}") - except Exception as e: - console.print(f" [red]An error occurred during setup:[/red] {e}") - - -def detect_default_branch(repo_path: Path) -> Optional[str]: - """Detect the repo's default branch using a fallback chain. - - 1. git symbolic-ref refs/remotes/origin/HEAD (local, instant) - 2. git remote show origin (network, ~1-2s) - 3. gh repo view --json defaultBranchRef (needs gh CLI + auth) - """ - # Fallback 1: local symbolic-ref (instant) - try: - result = subprocess.run( - ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], - cwd=repo_path, capture_output=True, text=True, - ) - if result.returncode == 0: - # Output: refs/remotes/origin/main - ref = result.stdout.strip() - branch = ref.removeprefix("refs/remotes/origin/") - if branch and branch != ref: - return branch - except Exception: - pass - - # Fallback 2: git remote show origin (network call, 10s timeout) - try: - result = subprocess.run( - ["git", "remote", "show", "origin"], - cwd=repo_path, capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - match = re.search(r"HEAD branch:\s+(\S+)", result.stdout) - if match: - return match.group(1) - except (subprocess.TimeoutExpired, Exception): - pass - - # Fallback 3: gh CLI (15s timeout) - try: - result = subprocess.run( - ["gh", "repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"], - cwd=repo_path, capture_output=True, text=True, timeout=15, - ) - if result.returncode == 0: - branch = result.stdout.strip() - if branch: - return branch - except (subprocess.TimeoutExpired, FileNotFoundError, Exception): - pass - - return None - - -def make_display_path(path: Path, scan_dirs: List[Path]) -> str: - """Make a path relative to the most relevant scan dir for display.""" - for scan_dir in scan_dirs: - try: - return str(path.relative_to(scan_dir)) - except ValueError: - continue - return str(path) diff --git a/src/spawnpoint/version_check.py b/src/spawnpoint/version_check.py deleted file mode 100644 index e819ed9..0000000 --- a/src/spawnpoint/version_check.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Non-blocking update checker. Runs PyPI lookup in a background thread.""" - -from __future__ import annotations - -import json -import threading -import time -import urllib.request -from pathlib import Path - -from .config import SPAWNPOINT_DIR - -_CACHE_PATH = SPAWNPOINT_DIR / ".version_cache.json" -_CACHE_TTL = 86400 # 24 hours -_PYPI_URL = "https://pypi.org/pypi/spawnpoint/json" - -_thread: threading.Thread | None = None -_latest_version: str | None = None -_current_version: str | None = None - - -def _read_cache() -> str | None: - """Return cached latest version if fresh, else None.""" - try: - data = json.loads(_CACHE_PATH.read_text()) - if time.time() - data["checked_at"] < _CACHE_TTL: - return data["latest_version"] - except Exception: - pass - return None - - -def _write_cache(latest: str) -> None: - try: - SPAWNPOINT_DIR.mkdir(parents=True, exist_ok=True) - _CACHE_PATH.write_text(json.dumps({ - "latest_version": latest, - "checked_at": time.time(), - })) - except Exception: - pass - - -def _fetch_latest() -> str | None: - try: - req = urllib.request.Request(_PYPI_URL, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=3) as resp: - data = json.loads(resp.read()) - return data["info"]["version"] - except Exception: - return None - - -def _check() -> None: - global _latest_version - cached = _read_cache() - if cached: - _latest_version = cached - return - latest = _fetch_latest() - if latest: - _latest_version = latest - _write_cache(latest) - - -def start_check(current_version: str) -> None: - """Kick off background version check. Call early in CLI startup.""" - global _thread, _current_version - _current_version = current_version - _thread = threading.Thread(target=_check, daemon=True) - _thread.start() - - -def get_update_notice() -> str | None: - """Join bg thread (short timeout) and return a notice string, or None.""" - if _thread is None: - return None - _thread.join(timeout=0.5) - if _latest_version is None or _current_version is None: - return None - if _latest_version == _current_version: - return None - # Compare versions to avoid false positives on dev/pre-release builds - try: - cur = tuple(int(x) for x in _current_version.split(".")[:3]) - lat = tuple(int(x) for x in _latest_version.split(".")[:3]) - if lat <= cur: - return None - except (ValueError, AttributeError): - pass - return ( - f"[dim]Update available: {_current_version} → {_latest_version}" - f" (run [bold]sp update[/bold] to upgrade)[/dim]" - ) diff --git a/tests/test_templates.py b/tests/test_templates.py deleted file mode 100644 index fceb2c5..0000000 --- a/tests/test_templates.py +++ /dev/null @@ -1,173 +0,0 @@ -from pathlib import Path - -import pytest -import typer - -from spawnpoint import templates -from spawnpoint.templates import ( - Template, - get_template, - load_templates, - merge_template, - preselect_template_repos, - save_templates, - template_detail, - upsert_template, -) - - -@pytest.fixture -def store(tmp_path, monkeypatch): - """Point the template store at a temp dir instead of ~/.spawnpoint.""" - path = tmp_path / "templates.toml" - monkeypatch.setattr(templates, "SPAWNPOINT_DIR", tmp_path) - monkeypatch.setattr(templates, "TEMPLATES_PATH", path) - return path - - -def test_load_returns_empty_when_no_file(store): - assert load_templates() == {} - - -def test_round_trip_preserves_fields(store): - save_templates({ - "image-gen": Template( - name="image-gen", - repos=["chottu", "backend"], - base="staging", - description="Image generation stack", - ) - }) - - loaded = load_templates() - assert list(loaded) == ["image-gen"] - assert loaded["image-gen"].repos == ["chottu", "backend"] - assert loaded["image-gen"].base == "staging" - assert loaded["image-gen"].description == "Image generation stack" - - -def test_optional_fields_round_trip_as_none(store): - save_templates({"bare": Template(name="bare", repos=["api"])}) - - loaded = load_templates()["bare"] - assert loaded.base is None - assert loaded.description is None - - -def test_names_needing_escaping_round_trip(store): - tricky = 'we"ird\\name.with dots' - save_templates({ - tricky: Template( - name=tricky, - repos=['nested/re"po', "back\\slash"], - description='quote " and backslash \\', - ) - }) - - loaded = load_templates() - assert list(loaded) == [tricky] - assert loaded[tricky].repos == ['nested/re"po', "back\\slash"] - assert loaded[tricky].description == 'quote " and backslash \\' - - -def test_templates_are_written_sorted(store): - save_templates({ - "zeta": Template(name="zeta", repos=["a"]), - "alpha": Template(name="alpha", repos=["b"]), - }) - - text = store.read_text() - assert text.index('[templates."alpha"]') < text.index('[templates."zeta"]') - - -def test_upsert_reports_whether_it_existed(store): - _, existed = upsert_template(Template(name="pair", repos=["a"])) - assert existed is False - - _, existed = upsert_template(Template(name="pair", repos=["a", "b"])) - assert existed is True - assert load_templates()["pair"].repos == ["a", "b"] - - -def test_upsert_leaves_other_templates_alone(store): - upsert_template(Template(name="one", repos=["a"])) - upsert_template(Template(name="two", repos=["b"])) - - assert sorted(load_templates()) == ["one", "two"] - - -def test_merge_keeps_saved_base_and_description(store): - upsert_template( - Template(name="t", repos=["a"], base="staging", description="original") - ) - - merged = merge_template("t", ["a", "b"]) - assert merged.repos == ["a", "b"] - assert merged.base == "staging" - assert merged.description == "original" - - -def test_merge_overrides_when_given(store): - upsert_template(Template(name="t", repos=["a"], base="staging", description="old")) - - merged = merge_template("t", ["a"], base="main", description="new") - assert merged.base == "main" - assert merged.description == "new" - - -def test_get_template_exits_on_unknown_name(store): - upsert_template(Template(name="known", repos=["a"])) - - with pytest.raises(typer.Exit) as excinfo: - get_template("unknown") - assert excinfo.value.exit_code == 1 - - -def test_malformed_toml_exits_cleanly(store): - store.write_text("this is not = = valid toml\n") - - with pytest.raises(typer.Exit) as excinfo: - load_templates() - assert excinfo.value.exit_code == 1 - - -def test_non_table_entries_are_skipped(store): - store.write_text('[templates]\nstray = "oops"\n') - - assert load_templates() == {} - - -def test_preselect_maps_labels_and_bare_dir_names(): - choice_to_path = { - "alpha": Path("/code/alpha"), - "nested/beta": Path("/code/nested/beta"), - } - template = Template(name="t", repos=["alpha", "beta"]) - - # "beta" resolves via the bare directory name of "nested/beta". - assert preselect_template_repos(template, choice_to_path) == ["alpha", "nested/beta"] - - -def test_preselect_skips_missing_repos_without_failing(): - choice_to_path = {"alpha": Path("/code/alpha")} - template = Template(name="t", repos=["alpha", "gone"]) - - assert preselect_template_repos(template, choice_to_path) == ["alpha"] - - -def test_preselect_dedupes_repeated_repos(): - choice_to_path = {"alpha": Path("/code/alpha")} - template = Template(name="t", repos=["alpha", "alpha"]) - - assert preselect_template_repos(template, choice_to_path) == ["alpha"] - - -def test_detail_prefers_description_then_repos(): - assert template_detail(Template(name="t", repos=["a"], description="desc")) == "desc" - assert template_detail(Template(name="t", repos=["a", "b"])) == "a, b" - - -def test_detail_truncates_long_repo_lists(): - detail = template_detail(Template(name="t", repos=[f"repo-{i}" for i in range(20)])) - assert len(detail) == 60 - assert detail.endswith("...") diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 14dde47..0000000 --- a/uv.lock +++ /dev/null @@ -1,287 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "inquirerpy" -version = "0.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pfzy" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "packaging" -version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, -] - -[[package]] -name = "pfzy" -version = "0.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "rich" -version = "14.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "spawnpoint" -source = { editable = "." } -dependencies = [ - { name = "inquirerpy" }, - { name = "rich" }, - { name = "typer" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "inquirerpy", specifier = ">=0.3" }, - { name = "rich", specifier = ">=13" }, - { name = "typer", specifier = ">=0.9" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - -[[package]] -name = "typer" -version = "0.24.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, -] diff --git a/video/.gitignore b/video/.gitignore new file mode 100644 index 0000000..72aae85 --- /dev/null +++ b/video/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +out/ diff --git a/video/README.md b/video/README.md new file mode 100644 index 0000000..79c95a3 --- /dev/null +++ b/video/README.md @@ -0,0 +1,11 @@ +# Spawnpoint video + +[Remotion](https://www.remotion.dev) source for the overview video (`assets/spawnpoint.mp4`, `assets/spawnpoint.gif`) and the GitHub social preview (`assets/social-preview.png`). + +```sh +npm install +npm run studio # live preview and scrub the timeline +npm run build # render mp4 + gif + social preview and copy them into ../assets +``` + +Scenes live in `src/scenes/`, and their order and lengths are set in `src/Video.tsx`. The tagline is `TAGLINE` in `src/scenes/Reveal.tsx`, which the outro and social preview also use. `npm run gif` needs `ffmpeg`. diff --git a/video/package-lock.json b/video/package-lock.json new file mode 100644 index 0000000..e2e7cba --- /dev/null +++ b/video/package-lock.json @@ -0,0 +1,4236 @@ +{ + "name": "spawnpoint-video", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spawnpoint-video", + "version": "1.0.0", + "dependencies": { + "@remotion/cli": "^4.0.529", + "@remotion/google-fonts": "^4.0.529", + "@remotion/transitions": "^4.0.529", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "remotion": "^4.0.529" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "typescript": "^7.0.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.56.1.tgz", + "integrity": "sha512-a+OY/BctP7zC4G/prHikooOVaoWXMD4+bUyEwHg2dtQiQdIwC6TDJdVYJu5dfrdSNOiAFkv1HKcYEeROdwMezg==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.56.1.tgz", + "integrity": "sha512-zbmorMVlF1izwtlz5mWnu7A1rJLM+V26I0pEFr9Td1EDTwE5uAGRSW+/UZlnjwj1t00Woe3Zonfh9kb7lRRBqg==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.56.1.tgz", + "integrity": "sha512-p66ovEq6mZt3ejje45NOju84hIJI4v8rHfsmc1D2J34ziV0USE8AdJBzmiwqxl6nZkIAQLNB19HzbVRcQfc51g==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.529.tgz", + "integrity": "sha512-yrOoPWjwYz+E6B4uRub2L08VYZgo37T9fRaFqhtenrBFMVmR6JhftpXHyizOpQWRbeEuk1KLLo+M2duLhbi2qw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.529", + "@remotion/studio": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "@remotion/timeline-utils": "4.0.529", + "@rspack/core": "1.7.11", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "7.1.4", + "esbuild": "0.28.1", + "react-refresh": "0.18.0", + "remotion": "4.0.529", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/canvas": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/canvas/-/canvas-4.0.529.tgz", + "integrity": "sha512-zemhIIfjf+4jxwH20n+HDSEE/DxbO8HvMGdD+moozbk75E868uZWSSKNHY9EaFVi3i7hwkSv03XEktsRFLQl9A==", + "license": "Remotion License", + "dependencies": { + "@remotion/player": "4.0.529", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/captions": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/captions/-/captions-4.0.529.tgz", + "integrity": "sha512-zXHY/FCqKqxOid60dDUVYWV/yRsIg+7F6/Qs4OMsouNqL6fC6jKwjA/+lsbimdxRUv/VUeaVVyNEufXfenQGNA==", + "license": "MIT" + }, + "node_modules/@remotion/cli": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.529.tgz", + "integrity": "sha512-1ONdF3q0adtw8FXW4CXeaSbzYV0vXIzJEm+b9mMrTDD9iyF+AKaLMzMUNespJaAvFxSxy/HV6hq17LS4UMfmiQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@babel/parser": "7.24.1", + "@remotion/bundler": "4.0.529", + "@remotion/media-utils": "4.0.529", + "@remotion/player": "4.0.529", + "@remotion/renderer": "4.0.529", + "@remotion/studio": "4.0.529", + "@remotion/studio-server": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "dotenv": "17.3.1", + "minimist": "1.2.6", + "prompts": "2.4.2", + "remotion": "4.0.529", + "semver": "7.5.3" + }, + "bin": { + "remotion": "remotion-cli.js", + "remotionb": "remotionb-cli.js", + "remotiond": "remotiond-cli.js" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/codemods": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/codemods/-/codemods-4.0.529.tgz", + "integrity": "sha512-OfzvplLep4kpnxjZfnAPDTMWl+SDaudvrTpWizG7py58enZDpygoC3GD5TxqVcHi3KYja68k9vnJz/+DaAiJzw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/studio-protocol": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "ast-types": "0.16.1", + "recast": "0.23.21", + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.529.tgz", + "integrity": "sha512-uzCs1izkHlbQGvASFvnSSXodh0dfMIqaCwXX/4Zx6o+ML8MIiYfvoMXMH22bFAU6T5HYFKREhO+PKc/iDZKe4A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.529.tgz", + "integrity": "sha512-5nPifOtkadthsrJTG4YGUR22HrAVML77zYoFKRWQGHboLcCik7UPZVy/X7yRu6HlGYuFULe0N7MKWude8jo2GA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.529.tgz", + "integrity": "sha512-hb1gOJPGiP588tTR2pfif6xkSvYOXh3qW2FHQOIRki97kj3EyANj/+0+naVroJhrpFpE6lDHz8BMDdd8Sc6MKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.529.tgz", + "integrity": "sha512-ZmGciox6hDUDIwiw2bq9KBtA6dK/y0ZNdsYpf/06rSZJMbWM3HhhLG0k+uBb/ELhQt8ZtuFBx8neN8qq5zi5/A==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.529.tgz", + "integrity": "sha512-MSsX7ut2AOrTsgxCNPR9IulNkiMnZ4waQcUd43l8yePlsirTHVvOseB7fJtOFHDmjYJWYbbm3IcfaUWQJa68Zw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.529.tgz", + "integrity": "sha512-Jada9IFuw4qY9YahXHVOgQy5tSRRLfTfaj3E5Xda7xJokju/hWUkBjUaGl6dtJmH+S+h19f5Tu2UH2J9M2v1gg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.529.tgz", + "integrity": "sha512-kyg7sG3bRdsKlcwDQ1yNMwDkAKpwlTtTrwbVWKcmii8LvHdMEDGPuteuQn6ULxwIAFqyzNkNAxhKrIjj9r8hZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/google-fonts": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/google-fonts/-/google-fonts-4.0.529.tgz", + "integrity": "sha512-wU4mKK6gYVPk1ms0/x7y/yUfUxXynxTXjcTNZp67z73oL5J5r0MYzTVi3pl9zB0NG4P4trXKt81N9VYpMSJ6+A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/licensing": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.529.tgz", + "integrity": "sha512-+hVV68ouCdjeNGKQOdDueH1OZej5biXGXLr9ADdiJr7MEl5l9mWPHa2Z9z45TwBHihMe5wKOQ5fmXHc+TBOjBg==", + "license": "MIT" + }, + "node_modules/@remotion/media": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/media/-/media-4.0.529.tgz", + "integrity": "sha512-ritB7TVRR3ZvYLNVCPwgZ33riUNn/B7IasroxLFXarMLZbz/9tHBbznO02Dth2ldkt643DTILUO/cBrnTtVWKg==", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.529", + "zod": "4.5.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.529.tgz", + "integrity": "sha512-HGIm6dRtNRmGZbFZUy2/3EIWI0LW11l46qlAQs5fqQ6h24Cp+HqAfzNE05J75Hbv/2JYD+e1f5EPU/WKqykzzA==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.529.tgz", + "integrity": "sha512-0tYUgdRhKFSOsmGdBPFJc0TGDOz5ovyl0h984+azm9Y18w2OQwy/86Y/Vy+sB9nLRmoqn/wlaPuyH5do6iVCQw==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/paths": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/paths/-/paths-4.0.529.tgz", + "integrity": "sha512-kTm3lehZkjpqLi+e1rdgsMoRulDVUTkZMOUFPu9tCZDDh+gVFqQcsuOnhYZlgxnSun8f8zvy9aP2khS3Oooq3Q==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.529.tgz", + "integrity": "sha512-unUylvuwwByzIitVea5iQsBlFNqbZRElvoYVxXzeGa0jpiQRty9fYVxt3wQ97LkPEH1J1BmkSITLBWw+y/GF3w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.529.tgz", + "integrity": "sha512-2/jo+awVY0J6d0ZDanmIVa1ZvVurDitQwjq/iFNGHRTHNAvtu1jIuY1l6OXFW0FG49KU7/2/VdOCB6+wqbF9tQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.529", + "@remotion/streaming": "4.0.529", + "execa": "5.1.1", + "remotion": "4.0.529", + "source-map": "0.8.0", + "ws": "8.21.0" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.529", + "@remotion/compositor-darwin-x64": "4.0.529", + "@remotion/compositor-linux-arm64-gnu": "4.0.529", + "@remotion/compositor-linux-arm64-musl": "4.0.529", + "@remotion/compositor-linux-x64-gnu": "4.0.529", + "@remotion/compositor-linux-x64-musl": "4.0.529", + "@remotion/compositor-win32-x64-msvc": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/shapes": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/shapes/-/shapes-4.0.529.tgz", + "integrity": "sha512-2XeXXsuKwna5OnBm2I6wv4UN78JSGZU1GzOFDIfgFBwQiucTKoFHGqJz8dPplQpg2ydgVzG+RCWfPUEwQfeWsw==", + "license": "MIT", + "dependencies": { + "@remotion/paths": "4.0.529", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.529.tgz", + "integrity": "sha512-jGY65mgj1o6giB3vBmcCkRE6xu1oxVks5ogpXje1ISj3bUaptT+81flCXS8K60bhCs7lwLk1jXETsmFZLT3tsA==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.529.tgz", + "integrity": "sha512-O+uL9nU8aM3xtPoTR7aFjuwipifwpAWi+RYKaWbL2/+G9bHUE+RHmQnp+3Y9az5VgXE5sR6Sh5tXSnGqPZhK2g==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/canvas": "4.0.529", + "@remotion/captions": "4.0.529", + "@remotion/codemods": "4.0.529", + "@remotion/media": "4.0.529", + "@remotion/media-utils": "4.0.529", + "@remotion/paths": "4.0.529", + "@remotion/player": "4.0.529", + "@remotion/renderer": "4.0.529", + "@remotion/studio-protocol": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "@remotion/timeline-utils": "4.0.529", + "@remotion/web-renderer": "4.0.529", + "@remotion/zod-types": "4.0.529", + "@tanstack/react-virtual": "3.14.9", + "mediabunny": "1.56.1", + "memfs": "3.4.3", + "open": "8.4.2", + "prismjs": "1.30.0", + "remotion": "4.0.529", + "semver": "7.5.3", + "zod": "4.5.4" + }, + "peerDependencies": { + "@remotion/video-matting": "4.0.529", + "@remotion/whisper-webgpu": "4.0.529", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@remotion/video-matting": { + "optional": true + }, + "@remotion/whisper-webgpu": { + "optional": true + } + } + }, + "node_modules/@remotion/studio-protocol": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio-protocol/-/studio-protocol-4.0.529.tgz", + "integrity": "sha512-Sndjwx2xcPRD1d2YutpzALFP6sR0t1CR9VVQXopqmgjei7Ag6HlKKw8RKT8CXd63GdiNYm3mIBsw4MMWGyiWCQ==", + "license": "Remotion License", + "dependencies": { + "zod": "4.5.4" + } + }, + "node_modules/@remotion/studio-server": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.529.tgz", + "integrity": "sha512-7il0R2ynvtd2omdCXsn3ze4XSEplHp/g9l/1TvRaK8OTgeRwHE6Kf0ko0WqsE55nzEjsOxkOo4MALA7K2p/Q7g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/bundler": "4.0.529", + "@remotion/codemods": "4.0.529", + "@remotion/renderer": "4.0.529", + "@remotion/studio-protocol": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "kiwi-schema": "0.5.0", + "memfs": "3.4.3", + "open": "8.4.2", + "recast": "0.23.21", + "remotion": "4.0.529", + "semver": "7.5.3", + "zod": "4.5.4" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.529.tgz", + "integrity": "sha512-WQoNqqmTeho932NEIzhL5yH7UOeDjSpI3Ek38g9LaDx+wFZ6jVmBYaaYqjMlAHWkmom/mRl8lbMDOxyLz/7/Jg==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.529.tgz", + "integrity": "sha512-nw+H4/Q/gzxXbKU/XD8Sw9L5bpuccPS1c/CtpgZvyeEAGzlsgYPsbHInTAa++oIkasZgVK1X59qWjj5GXabiig==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/transitions": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/transitions/-/transitions-4.0.529.tgz", + "integrity": "sha512-uPicVWsHjHbERMc7D9YsIsYLclh9/f5UIPNSHFLDLom6E5EVbYCtv1y6GrlJgFAlIhlWcrs5d3kcPOps8D7PUw==", + "license": "UNLICENSED", + "dependencies": { + "@remotion/paths": "4.0.529", + "@remotion/shapes": "4.0.529", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.529.tgz", + "integrity": "sha512-+QP9k2C8PlvBfA+hmJ17BP/my92q6O/TYEz8Qiqc1cuE+1hnPF1+iNemjv5ETEWEZ2FUwbxVOYqa9+2khmdTYQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@mediabunny/aac-encoder": "1.56.1", + "@mediabunny/flac-encoder": "1.56.1", + "@mediabunny/mp3-encoder": "1.56.1", + "@remotion/licensing": "4.0.529", + "mediabunny": "1.56.1", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.529.tgz", + "integrity": "sha512-EInt82hUV5dH6QjI4hn4PhB9gFXiJH4QrJKJYxDI8Bog56nVGWkKLQE3vUQGhT5+3EUPdQnWEEm/OACutXvHFg==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", + "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.11", + "@rspack/binding-darwin-x64": "1.7.11", + "@rspack/binding-linux-arm64-gnu": "1.7.11", + "@rspack/binding-linux-arm64-musl": "1.7.11", + "@rspack/binding-linux-x64-gnu": "1.7.11", + "@rspack/binding-linux-x64-musl": "1.7.11", + "@rspack/binding-wasm32-wasi": "1.7.11", + "@rspack/binding-win32-arm64-msvc": "1.7.11", + "@rspack/binding-win32-ia32-msvc": "1.7.11", + "@rspack/binding-win32-x64-msvc": "1.7.11" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", + "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.11", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.2.tgz", + "integrity": "sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001812", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001813", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", + "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.440", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.440.tgz", + "integrity": "sha512-SghDzqdJokdz8zP8YNlvS74+CwLRoUPDGvP/gFA+HrKVw+pOshUAgx8pXR37xl/u16zGae746rFNWvZ/22kU2Q==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kiwi-schema": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz", + "integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==", + "license": "MIT", + "bin": { + "kiwic": "cli.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mediabunny": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.56.1.tgz", + "integrity": "sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.529.tgz", + "integrity": "sha512-x0oWWwPPiC5cV6ms80USr69GmtbHc3MK5eFTwJpP0oN+emvgtvq+flovERaN29jy0wJW/ZGemSwo119iGCiH0g==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "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" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/video/package.json b/video/package.json new file mode 100644 index 0000000..cd98e88 --- /dev/null +++ b/video/package.json @@ -0,0 +1,28 @@ +{ + "name": "spawnpoint-video", + "version": "1.0.0", + "private": true, + "description": "Remotion source for the Spawnpoint overview video and social preview", + "scripts": { + "studio": "remotion studio src/index.ts", + "render": "remotion render src/index.ts Spawnpoint out/spawnpoint.mp4 --codec=h264 --crf=18", + "gif": "ffmpeg -y -i out/spawnpoint.mp4 -vf \"fps=12,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle\" out/spawnpoint.gif", + "social": "remotion still src/index.ts Social out/social-preview.png", + "build": "npm run render && npm run gif && npm run social && cp out/spawnpoint.mp4 out/spawnpoint.gif out/social-preview.png ../assets/" + }, + "dependencies": { + "@remotion/cli": "^4.0.529", + "@remotion/google-fonts": "^4.0.529", + "@remotion/transitions": "^4.0.529", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "remotion": "^4.0.529" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "typescript": "^7.0.2" + }, + "allowScripts": { + "esbuild@0.28.1": true + } +} diff --git a/video/public/audio/click.mp3 b/video/public/audio/click.mp3 new file mode 100644 index 0000000..69ddbc4 Binary files /dev/null and b/video/public/audio/click.mp3 differ diff --git a/video/public/audio/enter.mp3 b/video/public/audio/enter.mp3 new file mode 100644 index 0000000..eded92e Binary files /dev/null and b/video/public/audio/enter.mp3 differ diff --git a/video/public/audio/impact.mp3 b/video/public/audio/impact.mp3 new file mode 100644 index 0000000..1b0d691 Binary files /dev/null and b/video/public/audio/impact.mp3 differ diff --git a/video/public/audio/music.mp3 b/video/public/audio/music.mp3 new file mode 100644 index 0000000..cd56956 Binary files /dev/null and b/video/public/audio/music.mp3 differ diff --git a/video/public/audio/pop.mp3 b/video/public/audio/pop.mp3 new file mode 100644 index 0000000..d3f78e5 Binary files /dev/null and b/video/public/audio/pop.mp3 differ diff --git a/video/public/audio/tick.mp3 b/video/public/audio/tick.mp3 new file mode 100644 index 0000000..893f9f4 Binary files /dev/null and b/video/public/audio/tick.mp3 differ diff --git a/video/public/audio/whoosh.mp3 b/video/public/audio/whoosh.mp3 new file mode 100644 index 0000000..a226563 Binary files /dev/null and b/video/public/audio/whoosh.mp3 differ diff --git a/video/scripts/make_audio.py b/video/scripts/make_audio.py new file mode 100644 index 0000000..b6f5db3 --- /dev/null +++ b/video/scripts/make_audio.py @@ -0,0 +1,262 @@ +"""Synthesize the video's music bed and sound effects into public/audio/. + +Everything is generated from scratch (no samples), so the audio is ours to +ship. Timings come from src/Video.tsx; rerun after changing scene lengths: + + python scripts/make_audio.py --fps 30 --total 900 --reveal 185 --outro 750 + +Needs numpy and ffmpeg. +""" + +import argparse +import subprocess +import tempfile +import wave +from pathlib import Path + +import numpy as np + +SR = 44100 +OUT = Path(__file__).resolve().parent.parent / "public" / "audio" +rng = np.random.default_rng(7) + + +def t(n: int) -> np.ndarray: + return np.arange(n) / SR + + +def env(n: int, attack: float, decay: float) -> np.ndarray: + """Exponential-decay envelope with a short linear attack (seconds).""" + x = t(n) + a = np.clip(x / max(attack, 1e-4), 0, 1) + return a * np.exp(-x / decay) + + +def lowpass(x: np.ndarray, cutoff: np.ndarray | float) -> np.ndarray: + """One-pole lowpass; cutoff may vary per sample.""" + cutoff = np.broadcast_to(np.asarray(cutoff, dtype=float), x.shape) + a = 1 - np.exp(-2 * np.pi * cutoff / SR) + y = np.empty_like(x) + acc = 0.0 + for i in range(len(x)): + acc += a[i] * (x[i] - acc) + y[i] = acc + return y + + +def highpass(x: np.ndarray, cutoff: float) -> np.ndarray: + return x - lowpass(x, cutoff) + + +def saw(freq: float, n: int, phase: float = 0.0) -> np.ndarray: + p = (t(n) * freq + phase) % 1.0 + return 2 * p - 1 + + +def note(midi: float) -> float: + return 440.0 * 2 ** ((midi - 69) / 12) + + +def write(name: str, x: np.ndarray, peak: float = 0.9) -> None: + """Write mono or stereo float audio as mp3 via a temp wav.""" + if x.ndim == 1: + x = np.stack([x, x], axis=1) + x = x / (np.max(np.abs(x)) + 1e-9) * peak + pcm = (x * 32767).astype(" np.ndarray: + """Soft mechanical key tap.""" + n = int(0.05 * SR) + noise = highpass(rng.standard_normal(n), 2500) * env(n, 0.0005, 0.006) + body = np.sin(2 * np.pi * 1800 * t(n)) * env(n, 0.0005, 0.004) * 0.4 + return noise + body + + +def sfx_enter() -> np.ndarray: + """Heavier key: a thock plus a click.""" + n = int(0.12 * SR) + thock = np.sin(2 * np.pi * (180 + 200 * np.exp(-t(n) / 0.01)) * t(n)) * env(n, 0.001, 0.03) + return thock + sfx_click_padded(n) * 0.8 + + +def sfx_click_padded(n: int) -> np.ndarray: + c = sfx_click() + return np.pad(c, (0, n - len(c))) + + +def sfx_tick() -> np.ndarray: + """Bright two-note confirmation chime.""" + n = int(0.4 * SR) + out = np.zeros(n) + for i, (m, delay) in enumerate([(84, 0.0), (91, 0.06)]): + k = n - int(delay * SR) + f = note(m) + tone = (np.sin(2 * np.pi * f * t(k)) + 0.3 * np.sin(2 * np.pi * 2 * f * t(k))) * env(k, 0.002, 0.12) + out[n - k :] += tone * (0.8 if i else 1.0) + return out + + +def sfx_pop() -> np.ndarray: + """Bubbly pitch-up pop for elements appearing.""" + n = int(0.12 * SR) + freq = 380 + 900 * (1 - np.exp(-t(n) / 0.02)) + phase = 2 * np.pi * np.cumsum(freq) / SR + return np.sin(phase) * env(n, 0.002, 0.035) + + +def sfx_whoosh() -> np.ndarray: + """Filtered-noise sweep for scene transitions (~0.5 s).""" + n = int(0.55 * SR) + x = t(n) / (n / SR) + shape = np.sin(np.pi * x) ** 2 + cutoff = 300 + 5000 * np.sin(np.pi * x) ** 1.5 + noise = lowpass(rng.standard_normal(n), cutoff) + left = noise * shape * (1 - 0.6 * x) + right = noise * shape * (0.4 + 0.6 * x) + return np.stack([left, right], axis=1) + + +def sfx_impact() -> np.ndarray: + """Deep hit with a shimmering tail, for the logo reveal.""" + n = int(2.5 * SR) + boom = np.sin(2 * np.pi * (45 + 120 * np.exp(-t(n) / 0.05)) * t(n)) * env(n, 0.002, 0.5) + crack = lowpass(rng.standard_normal(n), 3000) * env(n, 0.001, 0.08) * 0.5 + shimmer = sum(np.sin(2 * np.pi * note(m) * t(n)) for m in (69, 76, 81, 88)) * env(n, 0.02, 0.9) * 0.12 + return boom + crack + shimmer + + +# --- music --------------------------------------------------------------- + +# A minor: Am – F – C – G, one chord per bar. +CHORDS = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]] +ROOTS = [45, 41, 36, 43] + + +def music(total_s: float, reveal_s: float, outro_s: float, bpm: float) -> np.ndarray: + n = int(total_s * SR) + beat = 60 / bpm + bar = beat * 4 + pad = np.zeros(n) + drums = np.zeros(n) + bass = np.zeros(n) + arp = np.zeros(n) + + def add(buf: np.ndarray, start_s: float, x: np.ndarray) -> None: + i = int(start_s * SR) + if i < 0: + x, i = x[-i:], 0 + if i >= n or len(x) == 0: + return + j = min(n, i + len(x)) + buf[i:j] += x[: j - i] + + # Bars are counted from the reveal so the drop lands on a downbeat. + first_bar = -int(np.ceil(reveal_s / bar)) + last_bar = int(np.ceil((total_s - reveal_s) / bar)) + for b in range(first_bar, last_bar): + start = reveal_s + b * bar + chord = CHORDS[b % 4] + # Pad: detuned saws, lowpassed, one bar long, plays throughout. + bn = int(bar * SR) + int(0.3 * SR) + voice = sum(saw(note(m) * d, bn, rng.random()) for m in chord for d in (0.997, 1.003)) + a = np.clip(t(bn) / 0.3, 0, 1) * np.clip((bn / SR - t(bn)) / 0.3, 0, 1) + add(pad, start, lowpass(voice, 900) * a) + + if start < reveal_s: + # Intro: a quiet high pulse on each beat to keep time. + for k in range(4): + pn = int(0.15 * SR) + add(arp, start + k * beat, np.sin(2 * np.pi * note(chord[2] + 12) * t(pn)) * env(pn, 0.003, 0.05) * 0.4) + continue + if start >= outro_s: + continue + + for k in range(4): + s = start + k * beat + # Kick on every beat. + kn = int(0.35 * SR) + add(drums, s, np.sin(2 * np.pi * (50 + 110 * np.exp(-t(kn) / 0.03)) * t(kn)) * env(kn, 0.001, 0.16)) + # Off-beat hats. + hn = int(0.06 * SR) + add(drums, s + beat / 2, highpass(rng.standard_normal(hn), 7000) * env(hn, 0.001, 0.015) * 0.25) + # Clap on 2 and 4. + if k in (1, 3): + cn = int(0.2 * SR) + add(drums, s, lowpass(highpass(rng.standard_normal(cn), 900), 5000) * env(cn, 0.001, 0.05) * 0.35) + # Bass: root on the off-beat eighths. + bn2 = int(beat / 2 * SR) + add(bass, s + beat / 2, lowpass(saw(note(ROOTS[b % 4]), bn2), 400) * env(bn2, 0.005, 0.12)) + # Arpeggio: 16ths through the chord, an octave up. + seq = chord + [chord[0] + 12] + for k in range(16): + an = int(0.2 * SR) + f = note(seq[k % 4] + 12) + tone = (np.sign(np.sin(2 * np.pi * f * t(an))) * 0.3 + np.sin(2 * np.pi * f * t(an))) * env(an, 0.002, 0.07) + add(arp, start + k * beat / 4, lowpass(tone, 2500) * 0.35) + + # Sidechain: duck the pad and bass under each kick after the reveal. + x = t(n) + since_beat = ((x - reveal_s) % beat) + duck = np.where((x >= reveal_s) & (x < outro_s), 1 - 0.6 * np.exp(-since_beat / 0.08), 1.0) + + # Riser into the reveal: noise sweep plus rising pitch over the last 2 bars. + rise_s = 2 * bar + rn = int(rise_s * SR) + rx = t(rn) / rise_s + riser = lowpass(rng.standard_normal(rn), 200 + 6000 * rx**2) * rx**2 * 0.5 + riser += np.sin(2 * np.pi * np.cumsum(200 + 600 * rx**2) / SR) * rx**3 * 0.15 + riser_buf = np.zeros(n) + add(riser_buf, reveal_s - rise_s, riser) + + # Intro is quieter; everything fades over the outro. + level = np.interp(x, [0, reveal_s - 0.01, reveal_s, outro_s, total_s - 0.2, total_s], [0.55, 0.7, 1, 1, 0, 0]) + level *= np.clip(x / 0.5, 0, 1) + mix = (pad * 0.12 * duck + bass * 0.35 * duck + drums * 0.55 + arp * 0.2) * level + riser_buf * 0.6 + + # A little stereo width: delay the arp and pad in the right channel. + d = int(0.012 * SR) + right = mix.copy() + right[d:] = (pad * 0.12 * duck + arp * 0.2)[:-d] * level[d:] + (bass * 0.35 * duck + drums * 0.55)[d:] * level[d:] + riser_buf[d:] * 0.6 + return np.stack([mix, right], axis=1) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--fps", type=float, default=30) + ap.add_argument("--total", type=int, required=True, help="total frames") + ap.add_argument("--reveal", type=int, required=True, help="frame where the logo reveal starts") + ap.add_argument("--outro", type=int, required=True, help="frame where the outro starts") + ap.add_argument("--bpm", type=float, default=112) + args = ap.parse_args() + + OUT.mkdir(parents=True, exist_ok=True) + for name, fn in [ + ("click", sfx_click), + ("enter", sfx_enter), + ("tick", sfx_tick), + ("pop", sfx_pop), + ("whoosh", sfx_whoosh), + ("impact", sfx_impact), + ]: + write(name, fn()) + write("music", music(args.total / args.fps, args.reveal / args.fps, args.outro / args.fps, args.bpm), peak=0.8) + print(f"wrote {OUT}") + + +if __name__ == "__main__": + main() diff --git a/video/src/Root.tsx b/video/src/Root.tsx new file mode 100644 index 0000000..0261df0 --- /dev/null +++ b/video/src/Root.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Composition, Still } from "remotion"; +import { Social } from "./Social"; +import { SpawnpointVideo, TOTAL } from "./Video"; + +export const Root: React.FC = () => ( + <> + + + +); diff --git a/video/src/Social.tsx b/video/src/Social.tsx new file mode 100644 index 0000000..e9727fb --- /dev/null +++ b/video/src/Social.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { AbsoluteFill } from "remotion"; +import { c, fonts } from "./theme"; +import { Background } from "./ui"; +import { Logo, SUBTAGLINE, TAGLINE } from "./scenes/Reveal"; + +/** Static 1280x640 GitHub social preview. */ +export const Social: React.FC = () => ( + + +
+ +
Spawnpoint
+
+
{TAGLINE}
+
{SUBTAGLINE}
+
+ {[ + ["api", c.blue], + ["web", c.purple], + ["worker", c.orange], + ].map(([r, col]) => ( + + {r}/ ⎇ feat/billing + + ))} +
+
+ $ pipx install spawnpoint +
+
+ Claude Code · Codex · Cursor · Gemini CLI +
+
+
+); diff --git a/video/src/Video.tsx b/video/src/Video.tsx new file mode 100644 index 0000000..8d47318 --- /dev/null +++ b/video/src/Video.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { linearTiming, TransitionSeries } from "@remotion/transitions"; +import { fade } from "@remotion/transitions/fade"; +import { slide } from "@remotion/transitions/slide"; +import { Background } from "./ui"; +import { Soundtrack } from "./audio"; +import { Chore, Hook } from "./scenes/Problem"; +import { Reveal } from "./scenes/Reveal"; +import { Demo, DEMO_LENGTH } from "./scenes/Demo"; +import { Agents } from "./scenes/Agents"; +import { Outro } from "./scenes/Outro"; + +const T = 15; + +export const SCENES = [ + { id: "hook", C: Hook, len: 75 }, + { id: "chore", C: Chore, len: 140 }, + { id: "reveal", C: Reveal, len: 100 }, + { id: "demo", C: Demo, len: DEMO_LENGTH }, + { id: "agents", C: Agents, len: 210 }, + { id: "outro", C: Outro, len: 150 }, +]; + +export const TOTAL = SCENES.reduce((a, s) => a + s.len, 0) - T * (SCENES.length - 1); + +// Absolute start frame of each scene (transitions overlap by T). +const STARTS = SCENES.map((s, i) => ({ + id: s.id, + from: SCENES.slice(0, i).reduce((a, p) => a + p.len - T, 0), +})); + +export const SpawnpointVideo: React.FC = () => ( + + + {SCENES.flatMap(({ id, C, len }, i) => { + const seq = ( + + + + ); + if (i === 0) return [seq]; + const presentation = i % 2 === 0 ? fade() : slide({ direction: "from-right" }); + return [ + , + seq, + ]; + })} + + + +); diff --git a/video/src/audio.tsx b/video/src/audio.tsx new file mode 100644 index 0000000..7d28f90 --- /dev/null +++ b/video/src/audio.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Html5Audio, Sequence, staticFile } from "remotion"; +import { DEMO_SFX } from "./scenes/Demo"; + +// Audio is synthesized by scripts/make_audio.py into public/audio/. +// Frames below are relative to each scene's start, and mirror the scene's +// own animation timings. + +type Kind = "click" | "enter" | "tick" | "pop" | "whoosh" | "impact"; +type Cue = { f: number; kind: Kind; vol?: number }; + +const VOLUME: Record = { + click: 0.3, + enter: 0.45, + tick: 0.35, + pop: 0.3, + whoosh: 0.35, + impact: 0.85, +}; + +const range = (n: number, at: (i: number) => number, kind: Kind, vol?: number): Cue[] => + Array.from({ length: n }, (_, i) => ({ f: at(i), kind, vol })); + +const SCENE_SFX: Record = { + hook: range(3, (i) => 14 + i * 7, "pop"), + // One keystroke per chore line, then the headline lands. + chore: [...range(13, (i) => 10 + (i + 1) * 5, "click", 0.25), { f: 81, kind: "pop" }], + reveal: [{ f: 0, kind: "impact" }], + demo: DEMO_SFX, + agents: [...range(3, (i) => 20 + i * 10, "pop"), ...range(9, (i) => 20 + (i % 3) * 10 + 66 + Math.floor(i / 3) * 12, "tick", 0.18)], + outro: [{ f: 22, kind: "tick" }, ...range(3, (i) => 30 + i * 3, "pop", 0.2)], +}; + +const Cue: React.FC<{ from: number; kind: Kind; vol?: number }> = ({ from, kind, vol }) => ( + + + +); + +/** `starts` maps scene id to its absolute start frame. */ +export const Soundtrack: React.FC<{ starts: { id: string; from: number }[] }> = ({ starts }) => ( + <> + + {starts.flatMap(({ id, from }, i) => [ + // Transitions start where the next scene starts; a whoosh rides each one. + ...(i > 0 && id !== "reveal" ? [] : []), + ...(SCENE_SFX[id] ?? []).map((cue, j) => ), + ])} + +); diff --git a/video/src/index.ts b/video/src/index.ts new file mode 100644 index 0000000..e78af8b --- /dev/null +++ b/video/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { Root } from "./Root"; + +registerRoot(Root); diff --git a/video/src/scenes/Agents.tsx b/video/src/scenes/Agents.tsx new file mode 100644 index 0000000..0ac24fd --- /dev/null +++ b/video/src/scenes/Agents.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion"; +import { c, fonts } from "../theme"; +import { Check, clamp, Eyebrow, FadeUp, useSpring, useTyped, Window } from "../ui"; + +const AGENTS = [ + { + name: "Claude Code", + color: "#d97757", + branch: "feat/billing", + repos: "api,web", + edits: ["api/billing/invoice.py", "web/app/billing/page.tsx", "api/tests/test_invoice.py"], + }, + { + name: "Codex", + color: "#10a37f", + branch: "fix/auth-timeout", + repos: "api,worker", + edits: ["api/auth/session.py", "worker/jobs/refresh.ts", "api/auth/middleware.py"], + }, + { + name: "Gemini CLI", + color: "#4f8cf7", + branch: "feat/search", + repos: "web,worker,infra", + edits: ["web/components/Search.tsx", "worker/index/build.ts", "infra/search.tf"], + }, +]; + +const AgentPane: React.FC<{ a: (typeof AGENTS)[number]; i: number }> = ({ a, i }) => { + const frame = useCurrentFrame(); + const delay = 20 + i * 10; + const s = useSpring(delay); + const cmd = useTyped(`spawnpoint create --no-input --json --repos ${a.repos} --branch ${a.branch}`, delay + 10, 80); + const jsonAt = delay + 50; + const editsAt = jsonAt + 16; + const slug = a.branch.replace("/", "-"); + return ( +
+ +
+ {a.name} + ⎇ {a.branch} +
+
+ $ + {cmd} +
+ {frame >= jsonAt && ( +
+ {"{ "} + "workspace": "~/…/workspaces/{slug}", +
+ {" "} + "repos": [ … "created" ] {"}"} +
+ )} +
+ {a.edits.map((e, j) => { + const at = editsAt + j * 12; + const p = interpolate(frame, [at, at + 10], [0, 1], clamp); + return ( +
+ + edited + {e} +
+ ); + })} +
+
+
+ ); +}; + +export const Agents: React.FC = () => { + const frame = useCurrentFrame(); + const pills = interpolate(frame, [140, 158], [0, 1], clamp); + return ( + + +
+ Built for coding agents +
+ Let your agents spawn their own workspaces. +
+
+
+
+ {AGENTS.map((a, i) => ( + + ))} +
+
+ ● Own branch per task + ● Own .env + deps + ● No stepping on each other +
+
+ ); +}; diff --git a/video/src/scenes/Demo.tsx b/video/src/scenes/Demo.tsx new file mode 100644 index 0000000..8fd4962 --- /dev/null +++ b/video/src/scenes/Demo.tsx @@ -0,0 +1,419 @@ +import React from "react"; +import { AbsoluteFill, useCurrentFrame } from "remotion"; +import { c, fonts, repoColors } from "../theme"; +import { Eyebrow, FadeUp, Window } from "../ui"; + +// A frame-accurate re-creation of `sp create` in the Go CLI. Everything is +// driven by the timeline below, which also feeds the sound effects +// (DEMO_SFX), so key clicks and ticks land exactly on what's drawn. + +const REPOS = ["api", "billing-docs", "design-system", "infra", "mobile", "web", "worker"]; + +const T = { + cmd: 4, // "sp create" starts typing + found: 24, + picker: 28, + pickerDone: 86, + branch: 90, + branchDone: 122, + fetch: 125, + fetchDone: 145, + base: 149, + baseDone: 165, + plan: 169, + proceed: 184, + proceedDone: 196, + build: 200, + npmDone: 232, + done: 252, + claude: 262, +}; +export const DEMO_LENGTH = 300; + +// Picker keystrokes: text is typed, "tab" toggles, "enter" confirms. +const PICKER_KEYS: { f: number; k: string }[] = [ + { f: 34, k: "a" }, + { f: 37, k: "p" }, + { f: 43, k: "tab" }, + { f: 50, k: "w" }, + { f: 53, k: "o" }, + { f: 56, k: "r" }, + { f: 62, k: "tab" }, + { f: 69, k: "w" }, + { f: 72, k: "e" }, + { f: 78, k: "tab" }, + { f: T.pickerDone, k: "enter" }, +]; +const BRANCH = "feat/billing"; +const typedAt = (start: number, text: string, every: number) => + text.split("").map((_, i) => start + i * every); +const CMD_KEYS = typedAt(T.cmd, "sp create", 2); +const BRANCH_KEYS = typedAt(T.branch + 4, BRANCH, 2); +const CLAUDE_KEYS = typedAt(T.claude + 4, "claude", 3); + +// Per-repo build steps: [frame, text, isSpinnerUntil?] +const BUILD: { f: number; repo?: string; line?: string; spinUntil?: number }[] = [ + { f: T.build + 2, repo: "api" }, + { f: T.build + 6, line: "worktree" }, + { f: T.build + 10, line: "copied .env, CLAUDE.md" }, + { f: T.build + 15, repo: "web" }, + { f: T.build + 19, line: "worktree" }, + { f: T.build + 23, line: "copied .env.local, CLAUDE.md" }, + { f: T.build + 27, line: "npm install", spinUntil: T.npmDone }, + { f: T.npmDone + 4, repo: "worker" }, + { f: T.npmDone + 8, line: "worktree" }, + { f: T.npmDone + 12, line: "copied .env" }, +]; + +export type Sfx = { f: number; kind: "click" | "enter" | "tick" | "pop" }; + +export const DEMO_SFX: Sfx[] = [ + ...CMD_KEYS.map((f) => ({ f, kind: "click" as const })), + { f: T.found - 4, kind: "enter" }, + ...PICKER_KEYS.map(({ f, k }) => ({ f, kind: (k.length === 1 ? "click" : k === "enter" ? "enter" : "pop") as Sfx["kind"] })), + { f: T.pickerDone + 1, kind: "tick" }, + ...BRANCH_KEYS.map((f) => ({ f, kind: "click" as const })), + { f: T.branchDone, kind: "enter" }, + { f: T.branchDone + 1, kind: "tick" }, + { f: T.fetchDone, kind: "tick" }, + { f: T.baseDone, kind: "enter" }, + { f: T.baseDone + 1, kind: "tick" }, + { f: T.proceedDone, kind: "enter" }, + { f: T.proceedDone + 1, kind: "tick" }, + ...BUILD.filter((b) => b.line).map((b) => ({ f: b.spinUntil ?? b.f, kind: "tick" as const })), + { f: T.done, kind: "pop" }, + ...CLAUDE_KEYS.map((f) => ({ f, kind: "click" as const })), +]; + +// --- picker model (mirrors internal/ui/picker.go) --------------------------- + +function fuzzy(query: string, s: string): number[] | null { + const hits: number[] = []; + let j = 0; + for (let i = 0; i < s.length && j < query.length; i++) { + if (s[i] === query[j]) { + hits.push(i); + j++; + } + } + return j === query.length ? hits : null; +} + +function pickerState(frame: number) { + let query = ""; + let cursor = 0; + const selected = new Set(); + const matches = () => + REPOS.map((r, i) => ({ i, hits: query ? fuzzy(query, r) : [] })).filter((m) => m.hits !== null) as { + i: number; + hits: number[]; + }[]; + for (const { f, k } of PICKER_KEYS) { + if (f > frame) break; + if (k === "tab") { + const ms = matches(); + const idx = ms[cursor]?.i; + if (idx !== undefined) { + if (selected.has(idx)) selected.delete(idx); + else selected.add(idx); + query = ""; + cursor = (idx + 1) % REPOS.length; + } + } else if (k.length === 1) { + query += k; + cursor = 0; + } + } + return { query, cursor, selected, matches: matches() }; +} + +// --- line primitives ------------------------------------------------------- + +const Line: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( +
{children}
+); +const Tick = () => ✓ ; +const Bar = () => ┃ ; +const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const Spinner: React.FC<{ frame: number }> = ({ frame }) => ( + {SPIN[Math.floor(frame / 2) % SPIN.length]} +); +const Caret = () => ▏; +const Answer: React.FC<{ q: string; a: string }> = ({ q, a }) => ( + + + {q} {a} + +); + +function typed(keys: number[], text: string, frame: number) { + return text.slice(0, keys.filter((f) => f <= frame).length); +} + +const Prompt: React.FC<{ dir: string; text: string; caret?: boolean }> = ({ dir, text, caret }) => ( + + {dir} ❯ {text} + {caret && } + +); + +function renderPicker(frame: number) { + const { query, cursor, selected, matches } = pickerState(frame); + const names = REPOS.filter((_, i) => selected.has(i)); + const rows: React.ReactNode[] = []; + for (let r = 0; r < REPOS.length; r++) { + const m = matches[r]; + if (!m) { + rows.push(); + continue; + } + const here = r === cursor; + const on = selected.has(m.i); + const label = REPOS[m.i]; + rows.push( + + {here ? "❯ " : " "} + {on ? "◉ " : "○ "} + {label.split("").map((ch, i) => ( + + {ch} + + ))} + , + ); + } + return [ + + Select repositories + {" type to search · tab/space toggle · enter confirm"} + , + + › + {query} + + {` ${matches.length}/${REPOS.length}`} + {names.length > 0 && ( + <> + {" · "} + {`selected (${names.length}): `} + {names.join(", ")} + + )} + , + ...rows, + ]; +} + +const Button: React.FC<{ on: boolean; children: React.ReactNode }> = ({ on, children }) => ( + + {children} + +); + +function terminalLines(frame: number): React.ReactNode[] { + const L: React.ReactNode[] = []; + L.push(); + if (frame < T.found) return L; + L.push( + + Found 7 repos in ~/code + , + ); + if (frame < T.picker) return L; + + if (frame < T.pickerDone) return [...L, ...renderPicker(frame)]; + L.push(); + if (frame < T.branch) return L; + + if (frame < T.branchDone) { + return [ + ...L, + + + Branch name: + , + + + {"> "} + {typed(BRANCH_KEYS, BRANCH, frame)} + + , + ]; + } + L.push(); + if (frame < T.fetch) return L; + + if (frame < T.fetchDone) { + return [ + ...L, + + + Fetching 3 repos… + , + ]; + } + L.push( + + + Fetched api, web, worker + , + ); + if (frame < T.base) return L; + + const baseQ = "'feat/billing' is new in api, web, worker. Create it from:"; + if (frame < T.baseDone) { + return [ + ...L, + + + {baseQ} + , + + + {"> "} + main + , + + + {" Other (manual input)"} + , + ]; + } + L.push(); + if (frame < T.plan) return L; + + L.push(); + L.push( + + Plan + , + ); + ["api", "web", "worker"].forEach((r, i) => { + if (frame >= T.plan + 4 + i * 4) { + L.push( + + {" "} + {r.padEnd(8)} + create branch {BRANCH} from{" "} + main + , + ); + } + }); + if (frame < T.proceed) return L; + L.push(); + + const confirm = (key: string, q: string, yes: boolean) => [ + + + {q} + , + + + + + , + ]; + if (frame < T.proceedDone) return [...L, ...confirm("pr", "Proceed?", true)]; + L.push(); + if (frame < T.build) return L; + L.push(); + + for (const [i, b] of BUILD.entries()) { + if (frame < b.f) break; + if (b.repo) { + L.push( + + {b.repo}{" "} + ~/.spawnpoint/workspaces/feat-billing/{b.repo} + , + ); + } else if (b.spinUntil && frame < b.spinUntil) { + L.push( + + {" "} + + installing deps · {b.line} + , + ); + break; + } else { + L.push( + + {" "} + + {b.line === "worktree" ? ( + <> + worktree on {BRANCH} + (from main) + + ) : b.line?.startsWith("copied") ? ( + <> + copied {b.line.slice(7)} + + ) : ( + b.line + )} + , + ); + } + } + if (frame < T.done) return L; + + L.push(); + L.push( + + ✓ Done! Workspace:{" "} + ~/.spawnpoint/workspaces/feat-billing + , + ); + if (frame < T.claude) return L; + L.push(); + return L; +} + +const TERM_H = 680; + +export const Demo: React.FC = () => { + const frame = useCurrentFrame(); + const lines = terminalLines(frame); + // Top-anchored until the screen fills, then scroll like a real terminal. + const full = lines.length * 40 > TERM_H; + return ( + + + sp create + + +
+ {lines} +
+
+
+ ); +}; diff --git a/video/src/scenes/Outro.tsx b/video/src/scenes/Outro.tsx new file mode 100644 index 0000000..0552024 --- /dev/null +++ b/video/src/scenes/Outro.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { AbsoluteFill } from "remotion"; +import { c, fonts } from "../theme"; +import { FadeUp, useSpring } from "../ui"; +import { Logo, TAGLINE } from "./Reveal"; + +export const Outro: React.FC = () => { + const s = useSpring(0, { damping: 13 }); + return ( + +
+ +
Spawnpoint
+
+ +
{TAGLINE}
+
+ +
+ $ brew install mihirgupta0900/tap/spawnpoint +
+
+ +
+ {["single native binary", "~20 ms startup", "sp update keeps it current"].map((t) => ( + + {t} + + ))} +
+
+ +
+ github.com/mihirgupta0900/spawnpoint +
+
+
+ ); +}; diff --git a/video/src/scenes/Problem.tsx b/video/src/scenes/Problem.tsx new file mode 100644 index 0000000..f457d4f --- /dev/null +++ b/video/src/scenes/Problem.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion"; +import { c, fonts, repoColors } from "../theme"; +import { BranchIcon, clamp, FadeUp, useSpring, Window } from "../ui"; + +const RepoCard: React.FC<{ name: string; delay: number }> = ({ name, delay }) => { + const s = useSpring(delay, { damping: 14 }); + const col = repoColors[name]; + return ( +
+
{name}
+
+ main +
+
+ ); +}; + +/** "Your feature touches 3 repos." */ +export const Hook: React.FC = () => ( + + +
+ One feature. Three repos. +
+
+
+ {["api", "web", "worker"].map((r, i) => ( + + ))} +
+
+); + +const CHORE = [ + { cmd: "cd ~/code/api && git fetch", tag: "api" }, + { cmd: "git worktree add ../api-billing -b feat/billing origin/main", tag: "api" }, + { cmd: "cp .env .env.local CLAUDE.md ../api-billing/", tag: "api" }, + { cmd: "cd ../api-billing && uv sync", tag: "api" }, + { cmd: "cd ~/code/web && git fetch", tag: "web" }, + { cmd: "git worktree add ../web-billing -b feat/billing origin/main", tag: "web" }, + { cmd: "cp .env* CLAUDE.md ../web-billing/", tag: "web" }, + { cmd: "cd ../web-billing && pnpm install", tag: "web" }, + { cmd: "cd ~/code/worker && git fetch", tag: "worker" }, + { cmd: "git worktree add ../worker-billing -b feat/billing …", tag: "worker" }, + { cmd: "git submodule update --init --recursive", tag: "worker" }, + { cmd: "cp .env ../worker-billing/ # wait, which .env?", tag: "worker" }, + { cmd: "cd ../worker-billing && npm install", tag: "worker" }, +]; + +/** The manual chore, cascading. */ +export const Chore: React.FC = () => { + const frame = useCurrentFrame(); + const perLine = 5; + const visible = Math.floor((frame - 10) / perLine); + const minutes = interpolate(frame, [10, 10 + CHORE.length * perLine], [0, 14], clamp); + const headline = useSpring(10 + CHORE.length * perLine + 6); + const dimTerm = interpolate(headline, [0, 1], [1, 0.25]); + + return ( + +
+ +
+ {CHORE.slice(0, Math.max(0, visible)).map((l, i) => ( +
+ ❯ + {l.cmd} +
+ ))} +
+
+
+ ⏱ {Math.floor(minutes).toString().padStart(2, "0")}:{Math.floor((minutes % 1) * 60).toString().padStart(2, "0")} of setup +
+
+ +
+
Every feature.
+
Every agent. Every time.
+
+
+
+ ); +}; diff --git a/video/src/scenes/Reveal.tsx b/video/src/scenes/Reveal.tsx new file mode 100644 index 0000000..8f1d22a --- /dev/null +++ b/video/src/scenes/Reveal.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion"; +import { c, fonts } from "../theme"; +import { clamp, FadeUp, useSpring } from "../ui"; + +export const Logo: React.FC<{ size?: number; progress?: number }> = ({ size = 120, progress = 1 }) => { + // A spawn point: a core with three branches radiating out. + const len = 40; + return ( + + {[ + { a: -90, col: c.blue }, + { a: 30, col: c.purple }, + { a: 150, col: c.orange }, + ].map(({ a, col }) => { + const r = (a * Math.PI) / 180; + const x = 50 + Math.cos(r) * len * progress; + const y = 50 + Math.sin(r) * len * progress; + return ( + + + + + ); + })} + + + ); +}; + +export const TAGLINE = "One branch. Every repo. Ready to code."; +export const SUBTAGLINE = "Isolated multi-repo workspaces for you and your coding agents."; + +export const Reveal: React.FC = () => { + const frame = useCurrentFrame(); + const burst = useSpring(0, { damping: 12, stiffness: 90 }); + const word = useSpring(8); + return ( + + {[0, 1, 2].map((i) => { + const t = interpolate(frame, [i * 6, i * 6 + 45], [0, 1], clamp); + return ( +
+ ); + })} +
+ +
+ Spawnpoint +
+
+ +
{TAGLINE}
+
+ +
{SUBTAGLINE}
+
+ + ); +}; diff --git a/video/src/theme.ts b/video/src/theme.ts new file mode 100644 index 0000000..6f01968 --- /dev/null +++ b/video/src/theme.ts @@ -0,0 +1,33 @@ +import { loadFont as loadInter } from "@remotion/google-fonts/Inter"; +import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono"; + +const inter = loadInter("normal", { weights: ["400", "500", "600", "700", "800"], subsets: ["latin"] }); +const mono = loadMono("normal", { weights: ["400", "500", "700"], subsets: ["latin"] }); + +export const fonts = { + sans: inter.fontFamily, + mono: mono.fontFamily, +}; + +export const c = { + bg: "#0a0d14", + bg2: "#0f141d", + panel: "#131a26", + border: "#232c3b", + text: "#e6edf3", + dim: "#8b95a5", + faint: "#4a5568", + blue: "#58a6ff", + green: "#3fb950", + purple: "#bc8cff", + orange: "#f0883e", + pink: "#ff7eb6", + yellow: "#e3b341", + red: "#f85149", +}; + +export const repoColors: Record = { + api: c.blue, + web: c.purple, + worker: c.orange, +}; diff --git a/video/src/ui.tsx b/video/src/ui.tsx new file mode 100644 index 0000000..57cd9c5 --- /dev/null +++ b/video/src/ui.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { c, fonts } from "./theme"; + +export const Background: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const frame = useCurrentFrame(); + const drift = frame * 0.15; + return ( + + + + {children} + + ); +}; + +export const useSpring = (delay = 0, config: { damping?: number; mass?: number; stiffness?: number } = {}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + return spring({ frame: frame - delay, fps, config: { damping: 18, stiffness: 120, ...config } }); +}; + +export const FadeUp: React.FC<{ + delay?: number; + distance?: number; + children: React.ReactNode; + style?: React.CSSProperties; +}> = ({ delay = 0, distance = 30, children, style }) => { + const s = useSpring(delay); + return ( +
{children}
+ ); +}; + +export const Eyebrow: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color = c.blue }) => ( +
+ {children} +
+); + +export const Window: React.FC<{ + title?: string; + children: React.ReactNode; + style?: React.CSSProperties; + accent?: string; +}> = ({ title, children, style, accent }) => ( +
+
+ {["#ff5f57", "#febc2e", "#28c840"].map((col) => ( +
+ ))} + {title && ( +
{title}
+ )} +
+
{children}
+
+); + +/** Types `text` from `start`, `cps` chars per second. */ +export const useTyped = (text: string, start: number, cps = 28) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const n = Math.max(0, Math.floor(((frame - start) / fps) * cps)); + return text.slice(0, Math.min(n, text.length)); +}; + +export const Cursor: React.FC<{ color?: string }> = ({ color = c.text }) => { + const frame = useCurrentFrame(); + const on = Math.floor(frame / 15) % 2 === 0; + return ( + + ); +}; + +export const Check: React.FC<{ size?: number; color?: string; progress?: number }> = ({ + size = 26, + color = c.green, + progress = 1, +}) => ( + + + + +); + +export const Chip: React.FC<{ color: string; children: React.ReactNode; style?: React.CSSProperties }> = ({ + color, + children, + style, +}) => ( + + {children} + +); + +export const BranchIcon: React.FC<{ size?: number; color?: string }> = ({ size = 20, color = c.dim }) => ( + + + + + + +); + +export const FolderIcon: React.FC<{ size?: number; color?: string }> = ({ size = 28, color = c.blue }) => ( + + + +); + +export const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const; +export const fadeOut = (frame: number, end: number, len = 12) => interpolate(frame, [end - len, end], [1, 0], clamp); diff --git a/video/tsconfig.json b/video/tsconfig.json new file mode 100644 index 0000000..9714625 --- /dev/null +++ b/video/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +}