diff --git a/README.md b/README.md index a78f067..643ceab 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,19 @@ npm install -g @telepath-computer/stash [Create a new repo on GitHub](https://github.com/new) to use for sync, then connect it: +```bash +stash connect ./dir-to-sync github:owner/repo +``` + +Or, from inside the folder: + ```bash cd dir-to-sync/ -stash connect github +stash connect . github:owner/repo ``` +If the path doesn't exist yet, `stash connect` creates it. + Follow the prompts to enter your repo & GitHub token (see below). Then, start syncing! @@ -47,7 +55,7 @@ Alternatively, a classic token with the `repo` scope also works. ## How it works -A stash is a folder with a `.stash/` directory that stores connection config and a snapshot of the last-synced file state. On each sync, Stash scans local files, fetches remote changes, and reconciles both sides using a version of Google's [diff-match-patch](https://github.com/google/diff-match-patch) algorithm (the same one used by Obsidian Sync). In watch or background mode, this runs whenever a file changes or every 30 seconds. +A stash is a folder registered in `~/.stash//`, which stores its connection config and a snapshot of the last-synced file state. On each sync, Stash scans local files, fetches remote changes, and reconciles both sides using a version of Google's [diff-match-patch](https://github.com/google/diff-match-patch) algorithm (the same one used by Obsidian Sync). In background mode, this runs whenever a file changes or every 30 seconds. 1. Scan local files against the stored snapshot 2. Fetch remote changes from the provider @@ -57,25 +65,20 @@ A stash is a folder with a `.stash/` directory that stores connection config and - **Smart text merging.** Edits to different regions combine cleanly. Overlapping edits preserve both sides instead of silently dropping content. - **Binary files** use last-modified-wins. -- **Automatic tracking.** Every file in the directory is synced except dotfiles, dot-directories, symlinks, and `.stash/` metadata. +- **Automatic tracking.** Every file in the directory is synced except dotfiles, dot-directories, and symlinks. - **Provider-agnostic.** GitHub is the built-in provider, but the transport layer is pluggable. See [docs/providers/overview.md](docs/providers/overview.md). ## Commands | Command | Description | | --------------------------------- | -------------------------------------------------------------------------- | -| `stash connect [name]` | Initialize a stash and add a named connection | -| `stash disconnect ` | Disconnect one named connection | -| `stash disconnect --all` | Disconnect the current stash completely | -| `stash disconnect --path ` | Disconnect a stash by path | -| `stash sync` | Sync once | -| `stash watch` | Watch and sync continuously in the foreground | -| `stash start` | Start background sync (resumes on restart) | -| `stash stop` | Stop and uninstall the background service | -| `stash status` | Show background sync state and every registered stash (from any directory) | -| `stash setup ` | Update provider credentials | -| `stash config set ` | Set a per-stash config value | -| `stash config get ` | Get a per-stash config value | +| `stash setup ` | Configure provider credentials | +| `stash connect ` | Connect a folder to a provider URI (creates the folder if missing) | +| `stash disconnect []` | Disconnect a folder from a provider URI | +| `stash relink ` | Move a stash to a new path (after renaming or moving the folder) | +| `stash status` | Show background sync state and every registered stash | +| `stash start` | Install and start the background sync service | +| `stash stop` | Stop and uninstall the background sync service | ## Using stash with git @@ -84,15 +87,15 @@ A stash is a folder with a `.stash/` directory that stores connection config and We recommend you avoid using `.git/` and Stash simultaneously. Stash is its own syncing service, and simply uses GitHub as a remote to store files. -*However*, you might really like git workflows, possess an insatiable rebellious streak, and still want to sync a stash with this tool and use git at the same time. If this is you, we recommend you **only use git locally**, and while **disable backgroud sync** (`stash stop`). Then you can create branches, use git, and only run `stash sync` manually when you have your changes safely merged back into main. +*However*, you might really like git workflows, possess an insatiable rebellious streak, and still want to sync a stash with this tool and use git at the same time. If this is you, we recommend you **only use git locally**, and **disable background sync** (`stash stop`) while you switch branches. -Disable `.git/` protections by editing the config while in your stash folder: +Bypass the `.git/` check by passing `--dangerously-allow-git` at connect time: ```bash -stash config set allow-git true +stash connect ./my-repo github:owner/repo --dangerously-allow-git ``` -Oh, and probably make a backup first. We'll be improving git compatability in future, so expect this restriction to be improved. +Oh, and probably make a backup first. We'll be improving git compatibility in future, so expect this restriction to be relaxed. ## FAQ diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..803bf14 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODO + +## Capabilities (user-facing) + +In rough priority order: + +1. **External sync target** (current branch: `spec/external-sync-target`) — see [`docs/plans/external-sync-target.md`](docs/plans/external-sync-target.md). +2. **Structured data syncing** — first-class support for syncing structured documents (JSON, YAML, TOML, etc.) with field-level merge semantics rather than whole-file last-writer-wins (granularity / merge behavior TBD). +3. **Direct p2p syncing** — sync between peers without a github intermediary; likely uses iroh and registers an `iroh:` URI scheme. + +## Infrastructure + +- **CLI↔daemon IPC** (Unix socket or SIGHUP) — replace the file-watch + poll fallback used in chunk #1 once we want synchronous "did the daemon pick this up?" confirmation, richer commands, or escape from `fs.watch` reliability quirks. Plumbing improvement, not a user-facing feature. diff --git a/docs/cli.md b/docs/cli.md index 6d638ec..48a999d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,480 +1,311 @@ # CLI -The CLI is the user-facing layer for provider setup, sync orchestration, and terminal output. +The CLI is the user-facing layer for provider setup, connection management, status display, and background-service lifecycle. -## Commands - -### `stash setup ` +All commands print plain text. Errors go to **stderr** with an `Error:` prefix and exit code 1; success messages go to **stdout**. Color is applied only when stdout is a TTY (via `picocolors`); non-TTY output is the same characters without ANSI codes. -Writes global provider configuration. +## Style -Example: - -```bash -stash setup github --token ghp_... -``` +These rules apply across every command. Color is applied via `picocolors` and only when stdout is a TTY; non-TTY output is the same characters without ANSI codes. -If required fields are not provided on the command line, the CLI prompts for them. Secret fields are masked. +### Palette -### `stash connect [name]` +Color is used sparingly. The aim is *one* accented token per line at most — usually a verb at the start, or a state value in the daemon line / status table. Connector words and URIs in plain confirmation lines stay default; URIs are only colored inside the `status` table (where the column structure makes the accent earn its place) or inside error messages (where it draws the eye to the offending value). -Initializes the current directory as a stash if needed, writes per-directory named connection config, and registers the stash for background sync. +| Role | Color | Used for | +|----------|---------|------------------------------------------------------------------| +| default | (none) | most content — paths, URIs in confirmation lines, connectors | +| accent | cyan | URIs inside the `status` table; URIs inside error messages | +| success | green | confirmation verbs (`Connected`, `Disconnected`, `Configured`, `Relinked`, `Already connected`, `Already linked`); daemon `running` | +| warn | yellow | daemon `stopped`; `(path missing)` marker; `unsupported` state | +| error | red | `Error:` prefix on stderr; daemon `error` state; `State` value when it's `error`; `Message` value when sourced from an error | +| muted | grey | hint suffixes, table row labels, time qualifiers — see below | -Example: +### What gets greyed -```bash -stash connect github --repo user/repo -stash connect github origin --repo user/repo -``` +- **Table row labels** in `status` (`Connection`, `State`, `Last sync`, `Pending`, `Message`) — let the values carry the weight. +- **Time qualifiers** in the `Last sync` row — `2m ago`, `14s ago`. +- **Hint suffixes** — the second line on `connect` (`Background sync is on. Run \`stash status\` to check.`) is fully muted; same for the `Run: stash relink …` line under a path-missing stash. -If `name` is omitted, stash uses the provider name as the connection name. +Connector words (`to`, `from`, `—`, `→`) in confirmation lines are **default**, not grey — they stay readable next to the verb. -If global setup fields are missing, `connect` collects and writes them before storing connection fields. +### Paths -Example output: +Paths in output are **tilde-shortened** (`/Users/rupert/Notes` → `~/Notes`). Stored canonical paths on disk remain absolute; this is rendering only. -```text -Connected origin. -``` +### Symbols -If background sync is already running: +- **`→`** (muted) separates source and destination on transitions (`relink ~/old → ~/new`, `connect ~/Notes → github:owner/notes` in non-confirmation contexts). +- **No emojis** in any output. +- **No bold** — color does the emphasis work. -```text -Connected origin. -Background sync is on · This stash is now syncing automatically -``` +### Punctuation -**TTY colors (stdout):** `Connected origin.` is default foreground. On the second line, `Background sync is on` is **green**; the middle dot `·` and `This stash is now syncing automatically` are **dim**. **Non-TTY:** same characters, no ANSI codes. +- **No trailing periods** on confirmation lines. Errors do end with periods. +- Connectors are lowercase prose (`to`, `from`, `at`), not symbols. -Connection names are unique within a stash, and **only one connection per stash** is allowed (see [Output examples and TTY colors](#output-examples-and-tty-colors)). +### Spacing -If the directory contains `.git/` and per-stash config does not have `allow-git: true`, `connect` still succeeds but prints a warning — see [Git safety warning on `connect`](#git-safety-warning-on-connect). +- **Every command's output ends with a blank line** so the shell prompt has breathing room. Tested in the integration suite. +- **Two-line `connect` output** (success + daemon hint) has no blank line between the two lines; the hint is rendered directly under the confirmation in muted grey. +- **`status`** uses a blank line between the daemon header, each per-stash block, and the count outro. +- **Errors** are tight: summary line on stderr, any detail lines immediately under it; the trailing blank line is on **stderr**, not stdout. -### `stash disconnect ` +## Commands -Disconnects one named connection from the current stash. +### `stash setup ` -If that was the last remaining connection, the CLI also removes the stash from the global background registry and deletes `.stash/`. +Writes global provider configuration to `~/.stash/config.json`. Always prompts for the provider's `global` fields; values from CLI flags (e.g. `--token`) override prompts. -Example **stdout** (one connection removed, others remain): +Re-running `setup` re-prompts and overwrites existing values. -```text -Disconnected origin. -``` +Example: -Example **stdout** (last connection — same message as `--all` after teardown): +```bash +stash setup github +# prompts for: Personal access token -```text -Disconnected stash. +stash setup github --token ghp_… # no prompt ``` -Unknown name exits with code `1` and **stderr**: +Output: ```text -Connection not found: backup +Configured github ``` -**TTY:** default foreground (no color) for these disconnect messages. +Color: `Configured` green; provider name default. -### `stash disconnect --all` +### `stash connect [--dangerously-allow-git]` -Disconnects the current stash completely. +Connects a folder to a provider URI. `` is required — pass `.` for the current directory. If `` does not exist, it is created (recursively). -The CLI removes all named connections, unregisters the stash from background sync, and removes `.stash/`. +The URI form is `:`, e.g. `github:owner/repo`. The CLI parses the provider, validates the connection, and registers the stash under `~/.stash//`. -### `stash disconnect --path ` +If the provider's global fields are not yet set (e.g. github token), `connect` prompts for them and persists the result to `~/.stash/config.json` *only after* the connection validates successfully. -Disconnects a stash by path from anywhere. +Examples: -If the directory still exists, stash also removes `.stash/`. If the path is not registered, the command prints `No stash registered at that path.` and leaves global state unchanged. - -### `stash disconnect` with no arguments - -Fails with: - -```text -argument required — run `stash disconnect `, `stash disconnect --all`, or `stash disconnect --path ` +```bash +stash connect . github:owner/repo # current directory +stash connect ~/Notes github:owner/notes # existing folder +stash connect ./inbox github:owner/inbox # creates ./inbox if missing ``` -### `stash config set ` - -Writes one per-stash config value into `.stash/config.json`. - -Current supported keys: - -- `allow-git` - -Rules: - -- unknown keys fail -- `true` and `false` are stored as JSON booleans -- the current directory must already be a stash - -Example **stdout**: +Output (new connection): ```text -allow-git=true +Connected github:owner/repo to ~/Notes +Background sync is on. Run `stash status` to check. ``` -**TTY:** default foreground. - -### `stash config get ` +Color: `Connected` green; everything else default; the second line entirely muted grey. -Prints one per-stash config value from `.stash/config.json`. +The second line appears only when the daemon is running. If the daemon isn't running (or the platform doesn't support it), only the first line prints. -Example **stdout** when set: +Output (idempotent re-run on the same URI): ```text -true +Already connected — github:owner/repo ``` -When the key is unset, prints an empty line (still **stdout**). **TTY:** default foreground. - -### `stash start` - -Installs and starts the OS-managed background sync service: +Color: `Already connected` green; everything else default. -- macOS uses a user `launchd` agent -- Linux uses a user `systemd` unit -- the installed service runs the hidden `stash daemon` entrypoint -- other platforms report that background sync is not supported - -Example output: +Output (`.git/` detected without `--dangerously-allow-git`), to **stderr**, exit 1: ```text -Background sync is on -Watching 3 stashes · starts on startup +Error: ~/code/myrepo is a git repository. +Pass --dangerously-allow-git if you really want stash to sync this folder. ``` -**TTY:** first line **green**; second line entirely **dim**. - -If the service is already installed and running: +Output (path already connected to a different URI), to **stderr**, exit 1: ```text -Background sync is already running +Error: ~/Notes is already connected to github:owner/old-repo. +Multi-connection per stash is not yet supported. Run `stash disconnect ~/Notes` first. ``` -**TTY:** entire line **green**. +### `stash disconnect []` -On an unsupported platform, `stash start` prints to **stdout** and sets exit code `1`: +Disconnects a stash from a provider URI. `` is required — pass `.` for the current directory. If `` is omitted and the stash has exactly one connection, drops that connection. -```text -Background sync is not supported on this platform -``` +If the stash has multiple connections (a future feature), `` is required. -**TTY:** entire line **red**. +The CLI atomically rewrites `~/.stash//stash.json` to drop the connection. The per-stash directory is *not* deleted by the CLI — when the last connection is removed, the daemon picks up the empty-connections tombstone and cleans up the directory. -### `stash stop` +Examples: -Stops and uninstalls the OS-managed background sync service for the current user. - -Example output: - -```text -Background sync is off -Run `stash start` to resume syncing 3 stashes +```bash +stash disconnect . # current directory +stash disconnect ~/Notes # only connection +stash disconnect ~/Notes github:owner/notes # explicit URI ``` -**TTY:** first line **red**; second line **dim**. - -If the service is not installed and not running: +Output: ```text -Background sync is not running +Disconnected github:owner/notes from ~/Notes ``` -**TTY:** entire line **red** (also used when the platform does not support background sync). - -### `stash sync` - -Runs one sync cycle. +Color: `Disconnected` green; everything else default. -On a **TTY**, a single live line is updated. Spinner frames are `◐`, `◓`, `◑`, `◒` in **yellow**, followed by the rest of the line (lowercase status text). - -Example progression: +Output (no stash at path), to **stderr**, exit 1: ```text -◐ checking... -◐ syncing... ↑ hello.md -◐ syncing... ↓ photo.jpg -✓ synced (1↑ 1↓) +Error: No stash found at ~/Notes. ``` -If there are no changes: +Output (multiple connections, no `` given), to **stderr**, exit 1: ```text -◐ checking... -✓ up to date +Error: Multiple connections at ~/Notes: + github:owner/notes + github:owner/notes-mirror +Pass the URI to disconnect: stash disconnect ~/Notes ``` -If sync fails: - -```text -✗ sync failed: network error -``` +### `stash relink [--dangerously-allow-git]` -**TTY:** `✗` is **red**; `sync failed:` and the message use the default foreground. +Moves a registered stash to a new path. The old path may no longer exist (e.g. you renamed the folder). The new path must exist. -If sync is blocked by git safety: +The CLI atomically rewrites `~/.stash//stash.json` with the new `path` field. The stash's id stays the same. -```text -✗ sync failed: git repository detected — run `stash config set allow-git true` to allow syncing -``` - -### `stash watch` - -Continuously syncs the current stash until interrupted. - -Behavior: - -- starts with an immediate sync -- debounces filesystem-triggered syncs by 1 second -- polls every 10 seconds to pick up remote-only changes -- resets the poll timer after each completed sync -- `.` triggers an immediate sync -- `q` and `Ctrl-C` stop the watcher -- waits for an in-flight sync to finish before normal shutdown - -Additional behavior worth preserving: - -- in TTY mode, watch shows a live status line with countdowns to the next poll -- in non-TTY mode, watch prints line-based results instead of updating one live line -- sync and watcher errors do not stop watch; it keeps retrying on the normal poll cadence -- filesystem events that arrive during a sync are queued and trigger a follow-up debounced sync - -If sync is blocked by git safety, watch shows the same error message and keeps retrying on the normal poll cadence until the user removes `.git/` or sets `allow-git`. - -If no provider is connected, watch exits with: - -```text -no connection configured — run `stash connect ` first -``` - -(Exact casing matches the CLI.) - -**TTY** watch mode (live line, examples): +Example: -```text -● up to date · checking in 8s +```bash +stash relink ~/OldFolder ~/Documents/NewFolder ``` -**TTY colors:** `●` **green**; `up to date`, the middle dot, and `checking in 8s` are **dim**. - -After a sync that applied changes: +Output: ```text -● 2↑ 1↓ · checking in 10s +Relinked ~/OldFolder → ~/Documents/NewFolder ``` -**TTY:** `●` **green**; the summary (`2↑ 1↓`) is **default** foreground; `·` and countdown are **dim**. +Color: `Relinked` green; everything else default. -When a sync errors but watch keeps retrying: +Output (no stash at old path), to **stderr**, exit 1: ```text -✗ sync failed: · retrying in 10s +Error: No stash found at ~/OldFolder. ``` -**TTY:** `✗` **red**; `sync failed:` and `` default foreground; ` · retrying in …` **dim**. - -**Non-TTY** (piped stdout), error status prints one line: +Output (a stash already exists at the new path), to **stderr**, exit 1: ```text -✗ sync failed: +Error: A stash already exists at ~/Documents/NewFolder. ``` -**TTY:** `✗` **red**; remainder default. - ### `stash status` -Prints **global** background sync state, then every stash path registered for background sync (from `~/.stash/config.json` or `$XDG_CONFIG_HOME/stash/config.json`). The current working directory does not matter. - -It shows: - -- whether background sync is running and how many stashes are registered -- a blank line, then for each registered path: basename, full path, and each connection’s provider label -- local pending changes and last sync time (from disk vs `snapshot.json`), or a background error when the daemon reported failure -- `Directory not found` / `Not a stash` when a registered path is missing or no longer a stash +Shows daemon liveness and a per-stash block for every registered stash. -Example output: +Output: ```text -Background sync is on · watching 2 stashes +Daemon: running -● notes - /Users/me/notes - origin (github) · Up to date · synced 2m ago +~/Notes +┌────────────┬──────────────────────────┐ +│ Connection │ github:owner/notes │ +│ State │ idle │ +│ Last sync │ 2m ago │ +│ Pending │ 3 modified, 1 added │ +└────────────┴──────────────────────────┘ -● work - /Users/me/work - origin (github) · Local changes: 1 added, 2 modified · synced 5m ago +~/Repos/television/.television +┌────────────┬─────────────────────────┐ +│ Connection │ github:owner/tv-state │ +│ State │ syncing │ +│ Last sync │ 14s ago │ +└────────────┴─────────────────────────┘ +2 stashes ``` -**TTY colors:** On the banner line, `Background sync is on` is **green**; `·` and `watching 2 stashes` are **dim**. For each healthy stash, `●` is **green**; the stash title (e.g. `notes`) is default; the path on the next line is **dim**. On connection lines, `origin (github)` is default; `·` and the status phrase (`Up to date · synced 2m ago`, etc.) are **dim**. +The per-stash table is rendered with `cli-table3`. Rows that are absent (no message, no pending changes) are omitted from the table. The `Message` row carries either the latest sync `summary` (e.g. `synced 3 modified`) or an `error` string — error wins when both are present. -When background sync is off: +Color in the table: row labels (left column) muted grey, values default — except the URI in the `Connection` row which is cyan, the `State` value when it's `error` which is red, and the `Message` value which is red when sourced from `error` (default fg when sourced from `summary`). -```text -Background sync is off -Run `stash start` to resume syncing 2 stashes - -``` - -**TTY:** first line **red**; second line **dim**. - -When the platform does not support background sync: +If a stash's path no longer exists, the table is replaced with a one-line warning: ```text -Background sync is not supported on this platform - +~/Old/Folder (path missing) +Run: stash relink ~/Old/Folder ``` -**TTY:** entire line **red**. +Color: path default; `(path missing)` yellow; the `Run:` hint line entirely muted grey. -When no stashes are registered: +Output (no stashes registered): ```text -No stashes connected yet — run `stash connect ` in a directory to get started -``` - -**TTY:** default foreground. - -When the daemon reported an error for a stash (example): - -```text -✗ notes - /Users/me/notes - origin (github) · rate limited -``` - -**TTY:** `✗` **red**; title default; path **dim**; `origin (github)` default; `·` **dim**; error text **red**. - -### Hidden: `stash daemon` - -Hidden daemon entrypoint used by the OS service. - -- loads the global background registry -- starts a headless watcher per registered stash -- hot-reloads when the global config changes -- writes `.stash/status.json` and capped `.stash/sync.log` for each stash - -## Config Locations - -- Global config: `~/.stash/config.json` or `$XDG_CONFIG_HOME/stash/config.json` -- Per-stash config: `.stash/config.json` - -Global config shape: +Daemon: running -```json -{ - "providers": { - "github": { "token": "ghp_..." } - }, - "background": { - "stashes": ["/Users/me/notes"] - } -} +No stashes registered. Run `stash connect ` to add one (e.g. `stash connect . github:owner/repo`). ``` -## Output examples and TTY colors +The `Daemon:` line is colored — `running` green, `stopped` yellow, `unsupported` yellow, `error` red — when stdout is a TTY. -This section is the **canonical reference** for exact user-visible text and **TTY** color. When stdout/stderr is **not** a TTY (pipe, file, CI), stash prints the **same words and punctuation** but **omits ANSI color codes**. - -### How to read the examples - -- **stdout** / **stderr** are called out per case. Most errors go to **stderr** (including all structured `stash connect` validation errors). -- **“Green / red / dim / yellow”** describe the ANSI foreground stash uses when the stream is a TTY (`createColors` in `src/ui/color.ts`). -- **“Default”** means the terminal’s normal text color (stash does not wrap that segment in a color code). - -### Color roles (quick reference) - -| Color | Typical use | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Green** | `✓`, healthy `●`, positive service lines (`Background sync is on`, `Background sync is already running`), synced summary in watch | -| **Red** | `✗`, service “off / not running / unsupported”, daemon error text in `stash status`, **first line** of structured `stash connect` errors | -| **Dim** | Paths, hints, `·` separators, countdowns, **continuation lines** after a red `connect` error, git-warning bullets, second lines of `start`/`stop` | -| **Yellow** | Spinner frame characters `◐ ◓ ◑ ◒`; the `Warning:` prefix on git safety | -| **Default** | Plain sentences such as `Connected origin.`, `Disconnected origin.`, `stash sync`’s `sync failed:` tail | - -### `stash connect` — validation errors (stderr) - -Emitted **before** any connect prompts when the command cannot succeed. - -**Duplicate name** (name already in `.stash/config.json`): - -```text -Connection already exists: origin -``` +### `stash start` -**TTY:** entire line **red** (via `CliDisplayError`). **Non-TTY:** same text, no codes. +Installs and starts the background sync daemon as a system service (launchd on macOS, systemd on Linux). -**Second connection name** (e.g. `stash connect github new` while `origin` already exists): +Output: ```text -This stash already has connection "origin" (only one is supported). -Disconnect it before adding "new": - stash disconnect origin +Daemon installed and running ``` -**TTY:** line 1 **red**; lines 2–3 **dim**. No blank line between line 1 and line 2. +Color: `running` green; the rest default. -**Invalid multi-connection config** (more than one key under `connections` in `.stash/config.json`): +Output on unsupported platforms, to **stderr**, exit 1: ```text -This stash has more than one connection in .stash/config.json (only one is supported). -Remove extra entries or run: - stash disconnect --all -Then connect again. +Error: This platform does not support background services yet. ``` -**TTY:** line 1 **red**; lines 2–4 **dim**. +### `stash stop` -### Git safety warning on `connect` (stdout) +Stops and uninstalls the background sync daemon. -After `Connected .` when `.git` exists and `allow-git` is not `true`: +Output: ```text -Warning: This directory contains .git. Stash will not sync until you either: - - remove .git, or - - run `stash config set allow-git true` (see "Using stash with git" in the README) +Daemon stopped ``` -**TTY:** `Warning:` **yellow**; the rest of each line **dim**. - -### `stash setup` (stdout) +Output if the daemon is not running, to **stderr**, exit 1: ```text -Configured github. +Error: Daemon is not running. ``` -**TTY:** default foreground (no color codes for this line). +### `stash daemon` (hidden) -### Other commands +Runs the background daemon in the foreground. Used internally by the system service; not normally invoked by users. Hidden from `--help`. -Success paths, `disconnect`, `config`, `sync`, `watch`, `status`, and `start`/`stop` edge cases are documented **with the same level of detail** under each command above; the **palette rules** here apply consistently. +## Provider config schema -## UI Style +Each provider class declares a `static configSchema: ConfigItem[]`. Each item has: -The CLI output intentionally uses a compact, app-like style: +- `name` — the field key (used for both the CLI flag `--` and the prompt label fallback). +- `label` — the human-friendly prompt text. +- `secret?` — if true, the prompt masks input (used for tokens). +- `global?` — if true, the field is provider-level (stored in `~/.stash/config.json`); if false (or omitted), it's per-connection (sourced from the URI). -- primary state lines use sentence case, for example `Background sync is on` -- secondary context lines are short and actionable, for example `Run \`stash start\` to resume syncing 3 stashes` -- transient spinner/live lines use **lowercase** status words in the spinner body, for example `checking...` and `syncing...` (spinner frames remain `◐◓◑◒`) -- local and global status phrases are capitalized consistently: `Up to date`, `Local changes`, `Waiting for first sync`, `Directory not found` -- counts should pluralize naturally, for example `Watching 1 stash` and `Watching 3 stashes` -- color is part of the UI contract in TTY mode — see [Output examples and TTY colors](#output-examples-and-tty-colors) for exact strings and segment-by-segment color +`stash setup` collects only the `global: true` fields. `stash connect` collects any `global: true` fields that aren't already set, plus per-connection fields from the URI. -These presentation details are intentional behavior, not incidental formatting. +GitHub provider: -## Scope +| name | label | secret | global | +|-------|------------------------------|--------|--------| +| token | Personal access token | ✓ | ✓ | +| id | Identifier (e.g. owner/repo) | | | -The CLI owns: +The `id` field is sourced from the URI: `github:owner/repo` parses to `{ provider: "github", config: { id: "owner/repo" } }`. -- prompting -- command parsing -- output rendering -- watch orchestration -- global config file I/O -- background service lifecycle commands +## Errors and exit codes -The CLI does not own sync logic or reconciliation rules; those live in `Stash`. +- Successful commands exit with code 0; their output goes to **stdout**. +- Any error exits with code 1; the message goes to **stderr** with an `Error:` prefix. +- Errors do not produce partial state. If a command fails before its mutation step, no files have been written. Mutations under the registry lock (`connect`, `disconnect`, `relink`) are atomic — either the `stash.json` write succeeds in full or the operation aborts. diff --git a/docs/plans/external-sync-target.md b/docs/plans/external-sync-target.md new file mode 100644 index 0000000..003800f --- /dev/null +++ b/docs/plans/external-sync-target.md @@ -0,0 +1,435 @@ +# Spec: External sync target + +Branch: `spec/external-sync-target` + +## Goal + +Stash should sync any folder without requiring a `.stash/` directory inside it. Per-stash metadata moves to `~/.stash//`. The motivating use case is syncing application-state folders like `~/.my-app` without polluting them. This becomes the only mode — no in-folder metadata. + +Existing in-folder stashes will be migrated by hand; no automatic migration in scope. + +## Final design + +### Stash identity + +A stash is identified by a **ULID** generated at creation time. Stable for the life of the stash — never changes, including across `relink`. The per-stash metadata directory is `~/.stash//`. + +- Each stash also has a `path` (the folder being synced), stored in its `stash.json` and shown to users. +- `stash.id` is the stash's primary key (e.g. `01HF7E2K3Y4Z5N8M9PQRSTVWXY`). Internal — users mostly see paths. +- Uniqueness of *path* (no two stashes for the same canonical path) is enforced by **scan-before-create** under a registry lock. See [Registry lock](#registry-lock). + +### Path canonicalisation + +Paths go through `realpath` at registration to deduplicate aliases (symlinks, case differences on APFS, macOS firmlinks like `/var` vs `/private/var`). The resolved canonical path is what's stored in `stash.json` and matched against during dedup scans. + +- **At registration** (`connect`, `relink `): call `realpath` on the input. The resolved path is what's stored verbatim in `stash.json`. If `realpath` fails (path doesn't exist) at registration time, error. +- **At lookup-when-missing** (`disconnect `, `relink `): don't try to re-canonicalise. Use `path.resolve(input)` and match against existing stash.json `path` fields. **Caveat**: alias forms (`/var` vs `/private/var`, case differences on APFS, symlinks that have since vanished) won't match the stored canonical form once the path is gone. Users in that case must pull the exact canonical path from `stash status` output. Always try the canonical path first. +- **`stash status` always prints the canonical path** so the user has an exact handle to feed into `relink`/`disconnect` when their folder is gone. + +### Registry lock + +`~/.stash/.lock` (mode 0600) is the single primitive that serialises all registry-mutating operations. Both the CLI and the daemon use it, via a shared helper: + +```ts +withRegistryLock(async () => { + // …read on-disk state, mutate, etc. Lock is held for the duration. +}); +``` + +Implementation: `O_CREAT | O_EXCL` with a brief retry loop. Released on exit (including via `process.on('exit')` and signal handlers). Stale-lock recovery: if the file exists but the holding pid is dead (or the file is older than 60s with no live pid), it's reclaimed. + +**Who takes the lock, and when:** + +| Caller | When | What runs under the lock | +|---|---|---| +| CLI `connect` | entire command | scan-before-create + `Stash.create()` (or tombstone resurrect) | +| CLI `disconnect` | entire command | scan + atomic rewrite of `stash.json` (writes `connections: []` for the last connection — never `rmdir`) | +| CLI `relink` | entire command | scan + atomic rewrite of `stash.json.path` | +| Daemon (any deletion site) | revalidate-and-rmdir step only | re-read on-disk state, `rmdir` only if precondition still holds | + +The lock guards three invariants: +1. No two stashes for the same canonical path (scan-before-create can race without it). +2. `relink`'s scan-then-write of `stash.json` is serialised with concurrent `connect`s. +3. Daemon `rmdir` doesn't race with a CLI resurrection (tombstone → reconnect) or with an in-flight CLI `mkdir → write stash.json`. + +Per-stash sync activity uses a different lock (`~/.stash//sync.lock`) and is unaffected. + +**Lock discipline.** The CLI never `rmdir`s a per-stash dir — only the daemon does. The CLI mutates `stash.json` (atomic rename); dir lifecycle (mkdir on create, rmdir on cleanup) belongs to the daemon (mkdir excepted: `Stash.create()` mkdirs as part of bringing a new stash into existence). The daemon does NOT hold the lock while waiting for in-flight syncs; it calls `Watch.stop()` first (which waits for the active sync to finish), THEN takes the lock for the brief revalidate-and-rmdir step. + +### Connections + +Each provider registers a URI scheme equal to its name. Connections are identified by URIs: + +``` +github:owner/repo +iroh:docaaaeumlbdgjz... (future) +s3:bucket/prefix (future) +``` + +**Multi-connection per stash is deferred.** For chunk #1, a stash holds at most one connection; `connect` errors if a connection already exists (with an idempotent no-op when the same URI is reconnected). The data model uses an array regardless — forward-compatible, no migration when multi-connection unlocks. + +### Architecture preserved + +Existing layering stays intact (per `docs/architecture.md`): + +- `Stash` is the core engine: file scanning, reconciliation, snapshots, sync lifecycle. Owns its `~/.stash//` metadata. +- `Watch` is reusable scheduling: polling, fs events, debounce. +- `Daemon` runs one `Watch` instance per registered stash. +- Providers are transport-only: `fetch()`, `get()`, `push()`. They don't merge files or mutate local disk. + +This spec doesn't touch those boundaries. Future p2p (chunk #3) is a *transport*, not a sync engine — stash core continues to handle reconciliation. The only optimisation a p2p provider may need is a way to signal the watch loop "peers updated, fetch now" instead of polling, but that's a small extension to the provider interface, not an architectural shift. + +### `~/.stash/` layout + +``` +~/.stash/ (mode 0700) +├── config.json (mode 0600 — holds secrets) +├── .lock (registry lock, present during connect/disconnect/relink) +├── 01HF7E2K3Y4Z5N8M9PQRSTVWXY/ (per-stash dir, keyed by ULID) +│ ├── stash.json (path + connections) +│ ├── snapshot.json (last-synced state) +│ ├── snapshot/ (text merge bases) +│ ├── status.json (last sync result, written by Watch) +│ ├── sync.log +│ └── sync.lock (present only during active sync) +└── 01HF8G3M4P5Q6R7S8T9UVWXYZ0/ + └── … +``` + +**`config.json`** holds **only** providers (e.g. github token). No registry index — the list of stashes IS the list of `~/.stash//` directories. + +```jsonc +{ + "providers": { + "github": { "token": "ghp_…" } + } +} +``` + +**`stash.json`** is the authoritative per-stash record: + +```jsonc +{ + "path": "/Users/rupert/Notes", + "connections": [ + { "provider": "github", "config": { "id": "rmanfredi/notes" } } + ] +} +``` + +The path lives only in `stash.json` — single source of truth, no drift risk. + +### Atomic writes + +Both `~/.stash/config.json` and `~/.stash//stash.json` are written atomically: write to `.tmp`, `chmod` (0600 for `config.json` since it carries secrets; default for others), then `rename`. Readers filter out `.tmp` files when listing. + +Prevents the daemon from observing partial JSON during a CLI write. + +### CLI + +```bash +stash connect [--dangerously-allow-git] +stash disconnect [] +stash relink [--dangerously-allow-git] +stash status +stash start / stop +stash setup +``` + +Removed from prior versions: `stash sync`, `stash watch`, `stash add`, `stash join`, `stash list`, `stash share`, `stash config set/get`. `status` is the single overview command. The daemon syncs continuously. + +### Per-command behavior + +#### `stash connect [--dangerously-allow-git]` + +- **Inputs.** Required `` (use `.` for cwd). Required `` in the form `:`. Optional `--dangerously-allow-git` flag (gates a safety check, not stored). +- **Behavior.** + 1. `mkdir -p ` (no-op if it already exists), then resolve to canonical absolute via `realpath`. + 2. Detect `.git/`. If present and `--dangerously-allow-git` not passed → error: *"git repository detected. Pass `--dangerously-allow-git` if you really want stash to sync this folder."* + - The flag is **not stored**. The check exists only at `connect` and `relink`. The runtime `.git/` check inside `Stash.sync()` (today at `stash.ts:162`) is **removed** — the connect/relink-time decision is final. Trade-off: if a user runs `git init` inside an already-synced folder, sync continues silently (no post-connect protection). Accepted; user explicitly opted in at registration. + 3. Parse URI: split on first `:` → `{ provider, config: { id } }`. Error if no colon. + 4. Look up provider class by scheme. Error if unknown. + 5. Read provider config from `~/.stash/config.json`'s `providers.`. If any `global: true` items are missing, prompt for them and **collect into memory only** (do not write to disk yet). + 6. Construct the provider with merged `{ ...storedGlobal, ...pendingGlobal, ...connectionConfig }` to validate. Error if constructor throws (e.g. github rejects a malformed id). **No mutations have happened yet** — if anything failed in steps 1–6, no state has changed on disk. + 7. **If any pending global items were collected**, atomically write the updated provider config to `~/.stash/config.json` now. (Validation passed; safe to persist.) + 8. Inside `withRegistryLock`: + - **Scan.** Read every `~/.stash/*/stash.json` (skipping `.tmp` files and dirs lacking a `stash.json`). Look for one whose `path` equals `canonicalPath`. + - **None found** (new stash): call `Stash.create(record, globalConfig)` — generates a ULID, mkdirs `~/.stash//`, atomically writes `stash.json`. + - **Found** (a stash already exists at this canonical path): read its `stash.json`. + - If `connections` is empty (tombstone from a prior `disconnect`): atomically rewrite with the new connection. Resurrected. (Id stays the same.) + - Else if a connection with this exact `{ provider, config }` already exists: idempotent no-op, print "Already connected." and exit. + - Else (different connection present): error per the multi-connection rule. + 9. Daemon picks up the new dir (or rewritten `stash.json`) via its watches. +- **Output.** + ``` + Connected github:rmanfredi/notes to /Users/rupert/Notes. + Background sync is on · syncing automatically + ``` + Or, on existing-other-connection error: + ``` + Error: /Users/rupert/Notes is already connected to github:rmanfredi/old-repo. + Multi-connection per stash is not yet supported. Run `stash disconnect /Users/rupert/Notes` first. + ``` + +#### `stash disconnect []` + +- **Inputs.** Required `` (use `.` for cwd). Optional `` of the connection to drop. +- **Behavior.** Inside `withRegistryLock`: + 1. Resolve input: try `realpath(input)` first; on ENOENT, fall back to `path.resolve(input)`. + 2. Scan `~/.stash/*/stash.json` and find the entry whose `path` matches. Error if none. + 3. If `` given: drop that connection. Error if the stash doesn't have that connection. + 4. If `` omitted: if the stash has exactly one connection, drop it; else error and list current URIs. + 5. **CLI never `rmdir`s the per-stash dir.** It atomically rewrites `stash.json` with the connection removed (or sets `connections: []` if last). + + After the lock is released: + - **Empty `connections` is the tombstone.** Daemon's reactive deletion site picks this up: `Watch.stop()`, then under `withRegistryLock` re-reads `stash.json` and `rmdir`s if still empty. + - If the daemon isn't running, the dir stays as a tombstone until the daemon's startup pass dispatches it through the same site. +- **Output.** + ``` + Disconnected github:rmanfredi/notes from /Users/rupert/Notes. + ``` + +#### `stash relink [--dangerously-allow-git]` + +- **Inputs.** Both required. +- **Behavior.** Inside `withRegistryLock`: + 1. Resolve ``: try `realpath()` first; on ENOENT, fall back to `path.resolve()`. Call this `oldCanonical`. + 2. Resolve `` via `realpath`. Error if it doesn't exist. Call this `newCanonical`. + 3. Detect `.git/` at ``. If present and `--dangerously-allow-git` not passed → error (same message as `connect`). + 4. If `oldCanonical === newCanonical`: no-op, exit. + 5. Scan `~/.stash/*/stash.json`: + - Find the entry matching `oldCanonical` → that's the stash to relink. Error if none. + - Verify no other entry already matches `newCanonical`. Error: *"a stash already exists at ``."* + 6. **Atomically rewrite `~/.stash//stash.json`** with `path: newCanonical`. Single atomic write — no rename, no two-step drift window. The id stays the same. + + After the lock is released: daemon picks up the `stash.json` edit via its periodic poll. Tears down old `Watch`, starts new one against the new path. (Same id, same dir — just a path change.) +- **Output.** + ``` + Relinked /Users/rupert/Notes → /Users/rupert/Documents/Notes. + ``` + +#### `stash status` + +- **Inputs.** None. +- **Behavior.** Daemon liveness header + one block per registered stash, with brief sync state. Sourced from each stash's `~/.stash//status.json` (written by the daemon's Watch instance). Missing-path stashes flagged with the suggested `relink` command. +- **Output.** + ``` + Daemon: running + + /Users/rupert/Notes + github:rmanfredi/notes + State: idle + Last sync: 2m ago + Pending: 3 modified, 1 added + + /Users/rupert/Repos/television/.television + github:rmanfredi/tv-state + State: syncing + Last sync: 14s ago + + /Users/rupert/Old/Folder (path missing — run `stash relink /Users/rupert/Old/Folder `) + ``` + +#### `stash start` / `stash stop` + +- **Inputs.** None. +- **Behavior.** Install/start the daemon as a system service (launchd on macOS, systemd on Linux); stop and uninstall. Unchanged from today. +- **Output.** `Daemon started.` / `Daemon stopped.` + +#### `stash setup ` + +- **Inputs.** Provider name. The provider's `global: true` `configSchema` items are prompted interactively (or supplied as flags via the existing `addFieldOptions` wiring). +- **Behavior.** Stores provider-level config under `providers.` in `~/.stash/config.json` (atomic write at mode 0600). Re-running `setup` re-prompts and overwrites. +- **Output.** `Configured github.` + +### Daemon behavior + +Two watches: + +- **`~/.stash/config.json`** — fires on provider-level config changes (e.g. github token rotation). +- **`~/.stash/`** (the directory) — fires on per-stash dir add/remove (i.e. `connect` / final `disconnect`). + +Plus a periodic poll (30–60s) covering edits to existing `stash.json` files (relink, future connection edits) — the dir set doesn't change but the contents do, so `fs.watch` on `~/.stash/` won't fire. + +On any signal, the daemon re-reads: +1. `~/.stash/config.json` for provider config. +2. `readdir(~/.stash/)` for the stash list. Each subdir's `stash.json` is read for path + connections. + +It then diffs against currently-running `Watch` instances. The reconciliation has three rules, in order: + +1. **Tombstone (`connections: []`) is a removal.** If a `stash.json` has empty connections, treat the entry as deleted: call `Watch.stop()` (waits for in-flight sync, NOT under lock), then dispatch to the reactive deletion site. This is the final-disconnect path. +2. **Removed tuple** (id no longer in the dir listing) → call `Watch.stop()`, then nothing else (dir is already gone). +3. **New / changed tuple** (id appeared, or `(stashRecord, providerConfig)` differs from last seen) → if there's an existing Watch, tear it down; then start a new `Watch` via `Stash.load(id, globalConfig)`. + +Tear-down-and-recreate; no in-place reconfiguration. Watches are cheap. + +**Deletion sites.** Only the daemon `rmdir`s per-stash dirs. There are three sites; all use `withRegistryLock` and revalidate the precondition under the lock before acting: + +```ts +// Reactive tombstone (daemon observed connections: [] for an active stash) +await watch.stop(); // wait for in-flight sync — NOT under lock +await withRegistryLock(async () => { + const r = await readStashJsonIfExists(id); + if (r && r.connections.length === 0) { + await rm(stashDir(id), { recursive: true }); + } + // else: user resurrected the tombstone; another reconciliation tick will pick up the new state +}); + +// Orphan GC (dir has no stash.json — partial state from a crashed mkdir) +await withRegistryLock(async () => { + const hasJson = await exists(stashJsonPath(id)); + const old = await mtimeOlderThan(stashDir(id), 5 * 60_000); + if (!hasJson && old) { + await rm(stashDir(id), { recursive: true }); + } +}); + +// Startup GC: walks `~/.stash/*/` and dispatches each dir to the matching site above (or no-op). +``` + +This closes the window where a CLI `connect` resurrects a tombstone (or completes an in-flight `mkdir → write stash.json`) between the daemon observing the precondition and acting on it. Each site re-reads from disk under the lock — never trusts cached state — so the precondition reflects ground truth at delete time. The lock is fast (CLI mutations are sub-second); `Watch.stop()` happens *before* taking the lock, so the lock isn't held while waiting for in-flight syncs. + +**Missing-path stashes** (`stash.json` references a `path` that doesn't exist) get a synthetic `status.json` with `kind: "missing"` and no `Watch`. Daemon revisits each tick; the moment the user `relink`s, a real Watch starts. (No "user just `mv`d the folder back" auto-detect — they have to relink.) + +**Incomplete-dir reads**: there is a brief window between `mkdir(~/.stash//)` and the atomic `stash.json` write where the dir exists but has no `stash.json` (or only `stash.json.tmp`). The daemon's reader **ignores any per-stash dir that lacks a non-tmp `stash.json`** during reconciliation. Such dirs are revisited on the next tick (still in-flight) or eventually become orphan-GC candidates (>5 min old without `stash.json` → reclaimed via the orphan-deletion site above). + +**Daemon-was-off-during-disconnect**: tombstone dirs persist as-is across daemon restarts. On startup, the daemon walks `~/.stash/*/` and dispatches each dir through the deletion sites above (tombstone dispatch picks up empty-connection dirs; orphan dispatch picks up old incomplete dirs; live dirs become reconciliation entries). + +### `Stash` class + +```ts +/** + * Stash is the per-stash sync engine. It owns: + * - the metadata directory at `~/.stash//` (snapshot, status, log, lock, merge bases); + * - file scanning and reconciliation against the user's `path`; + * - the sync lifecycle (push/fetch via providers). + * + * Stash does NOT own: + * - the registry (it's just a directory listing of `~/.stash/`); + * - the daemon lifecycle or watch scheduling; + * - global provider config — that's read elsewhere and passed in. + * + * Two ways to materialise a Stash: + * - Stash.create(record, globalConfig) — for new stashes (creates the dir, writes stash.json). + * - Stash.load(id, globalConfig) — for existing stashes (reads stash.json). + */ +class Stash { + readonly id: string; // ULID, generated at create() + readonly record: StashRecord; // { path, connections } + + private constructor(id: string, record: StashRecord, globalConfig: GlobalConfig) { ... } + + /** + * Create a new stash for `record.path`. Generates a fresh ULID and writes the per-stash + * dir + stash.json. MUST be called from inside `withRegistryLock` after a scan has confirmed + * no existing stash matches `record.path`. Stash itself does not take the lock. + */ + static async create(record: StashRecord, globalConfig: GlobalConfig): Promise { + const id = ulid(); + await mkdir(`~/.stash/${id}/`, { mode: 0o700 }); + await writeStashJson(`~/.stash/${id}/stash.json`, record); // atomic write + return new Stash(id, record, globalConfig); + } + + static async load(id: string, globalConfig: GlobalConfig): Promise { + const record = await readStashJson(`~/.stash/${id}/stash.json`); + return new Stash(id, record, globalConfig); + } + + // existing instance methods (sync, scan, etc.) — unchanged contract +} +``` + +`Stash.init()` is removed. `Stash.create()` and `Stash.load()` are pure operations — they read/write the metadata dir but don't take the registry lock. Callers (CLI commands, daemon reconciliation) wrap calls in `withRegistryLock` when needed. Tests typically use `Stash.load` against fixtures, or construct via the static factories with temp dirs. + +### `status.json` schema (per stash) + +```ts +{ + kind: "idle" | "syncing" | "error" | "missing" | "never"; + lastSync?: number; // unix ms + summary?: string; + error?: string; // present iff kind === "error" + currentOperation?: "scanning" | "fetching" | "merging" | "pushing"; + pendingChanges?: { added: number; modified: number; deleted: number }; +} +``` + +Existing fields preserved; `"missing"` is a new `kind`; `currentOperation` and `pendingChanges` are new optional fields the Watch can populate cheaply. + +### Provider interface + +The provider class, its constructor, and its `fetch`/`get`/`push` methods stay as they are. The only changes: + +- Drop the old `ProviderSpec { setup, connect }` type. +- Replace with a single `static configSchema: ConfigItem[]` on each provider class. +- Drop interactive prompts for non-global items at connect time — they come from the URI. + +```ts +type ConfigItem = { + name: string; + label: string; + secret?: boolean; + global?: boolean; // true → stored in ~/.stash/config.json's providers. + // false (default) → per-connection (sourced from URI in chunk #1) +}; + +class GitHubProvider { + static configSchema: ConfigItem[] = [ + { name: "token", label: "Personal access token", secret: true, global: true }, + { name: "id", label: "Identifier (e.g. owner/repo)" }, + ]; + + constructor(config: { token: string; id: string }) { ... } + // existing fetch / get / push +} +``` + +URIs are stash core's concern, not the provider's. Stash core has one generic parser: + +```ts +function parseUri(uri: string): { provider: string; config: { id: string } } { + const i = uri.indexOf(':'); + if (i < 0) throw new InvalidUriError(uri); + return { provider: uri.slice(0, i), config: { id: uri.slice(i + 1) } }; +} +``` + +At construction time, stash core merges global + connection config: + +```ts +const providerConfig = readGlobalProviderConfig(connection.provider); +const connectionConfig = connection.config; +const provider = new ProviderClass({ ...providerConfig, ...connectionConfig }); +``` + +URIs are reconstructed for display via `${provider}:${config.id}`. + +## Phasing + +1. **`~/.stash//` layout.** New `config.json` schema (providers only). Per-stash dirs keyed by ULID. Atomic writes utility. `withRegistryLock(fn)` helper (`~/.stash/.lock`, `O_CREAT | O_EXCL`, stale-lock recovery). `~/.stash/` at 0700, `config.json` and `.lock` at 0600. +2. **`Stash` class refactor.** Drop `Stash.init()`. Add `Stash.create()` (generates ULID) and `Stash.load()` static factories. Update doc comments. +3. **Provider spec rework.** Replace `ProviderSpec { setup, connect }` with `static configSchema: ConfigItem[]`. Update github's spec; rewire `runConnect` / `runSetup` / `addFieldOptions` callsites. +4. **Generic URI parser in stash core.** Split-on-first-`:` to `{ provider, config: { id } }`. CLI uses this in `connect`. +5. **Daemon registry watching.** Watch `config.json` + `~/.stash/` directory. Diff by `(id, record, providerConfig)` tuple. Tear-down-and-recreate. GC-on-startup. Periodic poll fallback. +6. **CLI rewrite.** New `connect` (with cwd default, `--dangerously-allow-git`, validate-before-mutate flow), `disconnect`, `relink`, `status`. Remove `sync`, `watch`, `add`, `join`, `list`, `share`, `config set/get`. + +## Out of scope + +- Migration of existing in-folder `.stash/` stashes (handled manually). +- Auto-detect moved folders (use `stash relink`; surface missing paths in `stash status`). +- Per-provider non-URI connect fields like `--branch`. URI carries primary identity only for now. +- Multi-connection per stash. Data model supports it; CLI rejects it. +- Keychain integration for secrets. Token stays plaintext in `~/.stash/config.json` (mode 0600). +- Synchronous CLI↔daemon confirmation. The daemon picks up changes asynchronously via watch + poll. (See [TODO.md](../../TODO.md) item 4.) +- Per-stash flags. `local-config.ts` and `stash config set/get` are removed entirely. `--dangerously-allow-git` is a connect/relink-time gate, not a stored flag. +- Manual `sync`/`watch` fallback for environments where the OS service mechanism is unavailable (no launchd / no systemd / etc.). Stash is daemon-only in chunk #1; users on those platforms wait for [TODO #4 (CLI↔daemon IPC)] or a future userland-daemon mode. + +## Future considerations + +These don't constrain this spec but should not be made harder by it: + +- **Structured data syncing.** First-class support for syncing structured documents (JSON, YAML, TOML, etc.) with field-level merge semantics rather than whole-file last-writer-wins. +- **Direct p2p syncing.** A future `iroh:` (or similar) provider, transport-only — stash core continues to handle reconciliation. Reads `stash share ` (or similar) output, pastes into another client. The URI-as-canonical-form decision and the `Stash.create`/`Stash.load` shape are designed to make this drop in cleanly. Likely also: an event-driven hook so the provider can wake the watch loop ("peers updated, fetch now") instead of polling, and a machine-level `~/.stash/identity.json` for the iroh NodeId. +- **Multi-connection.** Reuse the existing array shape on `stash.json`. Drop the "single-connection" check in `connect`. diff --git a/package-lock.json b/package-lock.json index fec0951..5bcbcd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "0.4.1", "license": "MIT", "dependencies": { - "@inquirer/prompts": "^8.3.0", + "@inquirer/prompts": "^8.4.2", "@parcel/watcher": "^2.5.6", "@rupertsworld/daemon": "^0.1.1", "@rupertsworld/disposable": "^0.1.0", "@rupertsworld/emitter": "^0.1.0", + "cli-table3": "^0.6.5", "commander": "^14.0.3", - "diff-match-patch": "^1.0.5" + "diff-match-patch": "^1.0.5", + "picocolors": "^1.1.1" }, "bin": { "stash": "dist/cli.js" @@ -31,6 +33,16 @@ "typescript-eslint": "^8.57.0" } }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -212,24 +224,24 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", - "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/checkbox": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.0.tgz", - "integrity": "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.4.tgz", + "integrity": "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -244,13 +256,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.8.tgz", - "integrity": "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -265,14 +277,14 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", - "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3", + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -291,14 +303,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.8.tgz", - "integrity": "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.1.tgz", + "integrity": "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/external-editor": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -313,13 +325,13 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.8.tgz", - "integrity": "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.13.tgz", + "integrity": "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -334,9 +346,9 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.3.tgz", - "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -355,22 +367,22 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", - "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/input": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.8.tgz", - "integrity": "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.12.tgz", + "integrity": "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -385,13 +397,13 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.8.tgz", - "integrity": "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.12.tgz", + "integrity": "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -406,14 +418,14 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.8.tgz", - "integrity": "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.12.tgz", + "integrity": "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -428,21 +440,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", - "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.1.0", - "@inquirer/confirm": "^6.0.8", - "@inquirer/editor": "^5.0.8", - "@inquirer/expand": "^5.0.8", - "@inquirer/input": "^5.0.8", - "@inquirer/number": "^4.0.8", - "@inquirer/password": "^5.0.8", - "@inquirer/rawlist": "^5.2.4", - "@inquirer/search": "^4.1.4", - "@inquirer/select": "^5.1.0" + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -457,13 +469,13 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.4.tgz", - "integrity": "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.8.tgz", + "integrity": "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -478,14 +490,14 @@ } }, "node_modules/@inquirer/search": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", - "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.8.tgz", + "integrity": "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -500,15 +512,15 @@ } }, "node_modules/@inquirer/select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", - "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.4.tgz", + "integrity": "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -523,9 +535,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", - "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1154,6 +1166,15 @@ "url": "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/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1183,6 +1204,21 @@ "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "license": "MIT" }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -1314,6 +1350,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1667,6 +1709,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1878,6 +1929,12 @@ "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/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -1980,6 +2037,32 @@ "url": "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/sponsors/isaacs" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 2579206..36540de 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,15 @@ }, "license": "MIT", "dependencies": { - "@inquirer/prompts": "^8.3.0", + "@inquirer/prompts": "^8.4.2", "@parcel/watcher": "^2.5.6", "@rupertsworld/daemon": "^0.1.1", "@rupertsworld/disposable": "^0.1.0", "@rupertsworld/emitter": "^0.1.0", + "cli-table3": "^0.6.5", "commander": "^14.0.3", - "diff-match-patch": "^1.0.5" + "diff-match-patch": "^1.0.5", + "picocolors": "^1.1.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/cli-main.ts b/src/cli-main.ts index df57c45..b3b0f92 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -1,35 +1,34 @@ import { existsSync } from "node:fs"; -import { access, constants, readFile, rm } from "node:fs/promises"; +import { access, constants, mkdir, readdir, realpath, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { basename, delimiter, join, resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import { input as promptInput, password as promptPasswordInput } from "@inquirer/prompts"; +import Table from "cli-table3"; import { Daemon, UnsupportedPlatformError } from "@rupertsworld/daemon"; import { Command, Option } from "commander"; -import { input, password } from "@inquirer/prompts"; +import picocolors from "picocolors"; import { - addBackgroundStash, - getBackgroundStashes, getProviderConfig, + getStashDirPath, + getStashPath, readGlobalConfig, - removeBackgroundStash, setProviderConfig, writeGlobalConfig, } from "./global-config.ts"; -import type { LocalConfig } from "./local-config.ts"; -import { readLocalConfig, writeLocalConfig } from "./local-config.ts"; import { runDaemon } from "./daemon.ts"; -import { needsMigration } from "./migrations.ts"; +import { writeJsonFile } from "./utils/fs.ts"; +import { withRegistryLock } from "./registry-lock.ts"; +import { InvalidUriError } from "./errors.ts"; import { providers } from "./providers/index.ts"; -import { CliDisplayError } from "./cli-display-error.ts"; import { Stash } from "./stash.ts"; -import { createColors } from "./ui/color.ts"; import { formatTimeAgo } from "./ui/format.ts"; -import { LiveLine } from "./ui/live-line.ts"; -import { SyncRenderer } from "./ui/sync-renderer.ts"; -import { watch as watchStash } from "./watch.ts"; -import type { Field, GlobalConfig, ProviderClass, StatusResult } from "./types.ts"; +import { parseUri } from "./uri.ts"; +import type { ConfigItem, GlobalConfig, ProviderClass, StashRecord } from "./types.ts"; const SERVICE_NAME = "stash-background"; const SERVICE_DESCRIPTION = "Stash background sync"; +const URI_PATTERN = /\b[a-z][a-z0-9-]*:[^\s]+/g; type ServiceHandle = { install(): Promise; @@ -44,117 +43,309 @@ type ServiceLaunch = { type CliDependencies = { cwd?: () => string; + stdin?: Readable; readGlobalConfig?: () => Promise; writeGlobalConfig?: (config: GlobalConfig) => Promise; + stashPath?: string; service?: ServiceHandle; runDaemon?: () => Promise; stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream; }; -type PersistedBackgroundStatus = { +type PersistedStatus = { kind?: string; lastSync?: string | null; summary?: string | null; error?: string | null; + currentOperation?: string | null; + pendingChanges?: { + added?: number; + modified?: number; + deleted?: number; + } | null; }; -const STASH_CONFIG_KEYS = new Set(["allow-git"]); +type RegistryEntry = { + id: string; + record: StashRecord; +}; + +type SetupDeps = Required>; + +type ConnectDeps = Required> & + Pick & { + getService: () => Promise; + }; + +type DisconnectDeps = Required> & Pick; + +type StatusDeps = Pick & { + getService: () => Promise; +}; + +type Colors = ReturnType; + +type PromptIo = { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +}; + +export class CliCommandError extends Error { + readonly summary: string; + readonly details: string[]; -function isStashConfigKey(key: string): key is "allow-git" { - return STASH_CONFIG_KEYS.has(key); + constructor(summary: string, details: string[] = []) { + super(summary); + this.name = "CliCommandError"; + this.summary = summary; + this.details = details; + } } -function isStashDirectory(dir: string): boolean { - return existsSync(join(dir, ".stash")); +export function cliError(summary: string, details: string[] = []): CliCommandError { + return new CliCommandError(summary, details); } -/** - * Before any connect prompts, reject connection names that conflict with existing - * `.stash/config.json` state. Keeps expensive/interactive setup from running when - * the command cannot succeed. - */ -async function assertNewConnectionAllowedForDirectory( - dir: string, - resolvedConnectionName: string, -): Promise { - if (!isStashDirectory(dir)) { - return; +export function shortenHomePath(path: string, home = process.env.HOME): string { + if (!home) { + return path; } + if (path === home) { + return "~"; + } + return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} - const local = await readLocalConfig(dir); - const names = Object.keys(local.connections); +export function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} - if (names.includes(resolvedConnectionName)) { - throw new CliDisplayError([ - { kind: "primary", text: `Connection already exists: ${resolvedConnectionName}` }, - ]); +export function createColors(isTTY: boolean): ReturnType { + return picocolors.createColors(isTTY); +} + +export function writeLine(stream: NodeJS.WritableStream, text = ""): void { + stream.write(`${text}\n`); +} + +function writeBlankLine(stream: NodeJS.WritableStream): void { + writeLine(stream); +} + +function isPromptCancelled(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; } + return ( + error.name === "ExitPromptError" || + error.name === "AbortPromptError" || + /cancelled|canceled|force closed|sigint/i.test(error.message) + ); +} - if (names.length > 1) { - throw new CliDisplayError([ +export async function promptText(label: string, io: PromptIo = {}): Promise { + try { + return await promptInput( { - kind: "primary", - text: "This stash has more than one connection in .stash/config.json (only one is supported).", + message: label, + validate: (candidate) => { + if (!candidate || candidate.trim().length === 0) { + return `${label} is required`; + } + return true; + }, }, - { kind: "dim", text: "Remove extra entries or run:" }, - { kind: "dim", text: " stash disconnect --all" }, - { kind: "dim", text: "Then connect again." }, - ]); + io, + ); + } catch (error) { + if (isPromptCancelled(error)) { + throw cliError("Prompt cancelled."); + } + throw error; } +} - if (names.length === 1) { - const existing = names[0]; - throw new CliDisplayError([ +export async function promptPassword(label: string, io: PromptIo = {}): Promise { + try { + return await promptPasswordInput( { - kind: "primary", - text: `This stash already has connection "${existing}" (only one is supported).`, + message: label, + validate: (candidate) => { + if (!candidate || candidate.trim().length === 0) { + return `${label} is required`; + } + return true; + }, }, - { kind: "dim", text: `Disconnect it before adding "${resolvedConnectionName}":` }, - { kind: "dim", text: ` stash disconnect ${existing}` }, - ]); + io, + ); + } catch (error) { + if (isPromptCancelled(error)) { + throw cliError("Prompt cancelled."); + } + throw error; + } +} + +function getGlobalConfigItems(provider: ProviderClass): ConfigItem[] { + return provider.configSchema.filter((item) => item.global === true); +} + +function getCliValue( + field: ConfigItem, + valuesFromCli: Record, +): string | undefined { + const value = valuesFromCli[field.name]; + return typeof value === "string" ? value : undefined; +} + +async function promptField( + io: PromptIo, + field: ConfigItem, +): Promise { + if (field.secret) { + return promptPassword(field.label, io); + } + return promptText(field.label, io); +} + +async function collectFields( + io: PromptIo, + fields: ConfigItem[], + valuesFromCli: Record, + current: Record = {}, +): Promise> { + const values: Record = { ...current }; + for (const field of fields) { + if (values[field.name]) { + continue; + } + const cliValue = getCliValue(field, valuesFromCli); + if (cliValue) { + values[field.name] = cliValue; + continue; + } + values[field.name] = await promptField(io, field); + } + return values; +} + +function addFieldOptions(command: Command, fields: ConfigItem[]): void { + for (const field of fields) { + command.addOption(new Option(`--${field.name} `, field.label)); } } function getProvider(name: string): ProviderClass { const provider = providers[name]; if (!provider) { - throw new Error(`Unknown provider: ${name}`); + throw cliError(`Unknown provider: ${name}.`); } return provider; } -async function isExecutable(path: string): Promise { - try { - await access(path, constants.X_OK); - return true; - } catch { +function isUnsupportedPlatformError(error: unknown): boolean { + return error instanceof UnsupportedPlatformError; +} + +function formatUri(connection: StashRecord["connections"][number]): string { + return `${connection.provider}:${connection.config.id ?? ""}`; +} + +function sameConnection( + left: StashRecord["connections"][number], + right: StashRecord["connections"][number], +): boolean { + return left.provider === right.provider && JSON.stringify(left.config) === JSON.stringify(right.config); +} + +function sameStringRecord(left: Record, right: Record): boolean { + const leftKeys = Object.keys(left).sort((a, b) => a.localeCompare(b)); + const rightKeys = Object.keys(right).sort((a, b) => a.localeCompare(b)); + if (leftKeys.length !== rightKeys.length) { return false; } + return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]); } -function writeLine(stream: NodeJS.WriteStream, text: string): void { - stream.write(`${text}\n`); +function asSentence(message: string): string { + const trimmed = message.trim(); + if (trimmed.length === 0) { + return "Unknown error."; + } + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; } -function isUnsupportedPlatformError(error: unknown): boolean { - return error instanceof UnsupportedPlatformError; +function toCliCommandError(error: unknown): CliCommandError { + if (error instanceof CliCommandError) { + return error; + } + if (error instanceof Error) { + return cliError(asSentence(error.message)); + } + return cliError(asSentence(String(error))); } -async function bounceDaemonIfMigrationNeeded( - dir: string, - getService: () => Promise, -): Promise { - if (!isStashDirectory(dir) || !(await needsMigration(dir))) { - return false; +function colorizeUris(text: string, colors: Colors): string { + return text.replace(URI_PATTERN, (uri) => { + const punctuation = /[.,!?]$/.test(uri) ? uri.slice(-1) : ""; + const bareUri = punctuation ? uri.slice(0, -1) : uri; + return `${colors.cyan(bareUri)}${punctuation}`; + }); +} + +function renderCliError(stream: NodeJS.WritableStream, error: CliCommandError, colors: Colors): void { + writeLine(stream, `${colors.red("Error:")} ${colorizeUris(error.summary, colors)}`); + for (const detail of error.details) { + writeLine(stream, colorizeUris(detail, colors)); } +} + +function formatConfigured(providerName: string, colors: Colors): string { + return `${colors.green("Configured")} ${providerName}`; +} + +function formatAlreadyConnected(uri: string, colors: Colors): string { + return `${colors.green("Already connected")} — ${uri}`; +} + +function formatConnected(uri: string, path: string, colors: Colors): string { + return `${colors.green("Connected")} ${uri} to ${path}`; +} + +function formatDisconnected(uri: string, path: string, colors: Colors): string { + return `${colors.green("Disconnected")} ${uri} from ${path}`; +} + +function formatRelinked(oldPath: string, newPath: string, colors: Colors): string { + return `${colors.green("Relinked")} ${oldPath} → ${newPath}`; +} + +function formatAlreadyLinked(path: string, colors: Colors): string { + return `${colors.green("Already linked")} — ${path}`; +} + +function formatConnectHint(colors: Colors): string { + return colors.gray("Background sync is on. Run `stash status` to check."); +} + +function formatDaemonState(state: "running" | "stopped" | "unsupported" | "error", colors: Colors): string { + if (state === "running") { + return `Daemon: ${colors.green("running")}`; + } + if (state === "stopped") { + return `Daemon: ${colors.yellow("stopped")}`; + } + if (state === "unsupported") { + return `Daemon: ${colors.yellow("unsupported")}`; + } + return `Daemon: ${colors.red("error")}`; +} + +async function isExecutable(path: string): Promise { try { - const service = await getService(); - const status = await service.status(); - if (!status.running) { - return false; - } - await service.uninstall(); + await access(path, constants.X_OK); return true; } catch { return false; @@ -186,538 +377,468 @@ export async function resolveServiceLaunch(argv = process.argv): Promise { - if (field.secret) { - return password({ message: field.label }); +async function createDefaultService(argv = process.argv): Promise { + const launch = await resolveServiceLaunch(argv); + return new Daemon({ + name: SERVICE_NAME, + description: SERVICE_DESCRIPTION, + command: launch.command, + args: launch.args, + env: process.env.PATH ? { PATH: process.env.PATH } : undefined, + }); +} + +async function canonicalizeExistingPath(path: string): Promise { + return realpath(resolve(path)); +} + +async function resolveLookupPath(path: string): Promise { + try { + return await canonicalizeExistingPath(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return resolve(path); + } + throw error; } - return input({ message: field.label }); } -async function collectFields( - fields: Field[], - valuesFromCli: Record, - current: Record = {}, -): Promise> { - const values: Record = { ...current }; - for (const field of fields) { - if (values[field.name]) { +function ensureGitAllowed(path: string, dangerouslyAllowGit: boolean): void { + if (dangerouslyAllowGit) { + return; + } + if (existsSync(join(path, ".git"))) { + throw cliError(`${shortenHomePath(path)} is a git repository.`, [ + "Pass --dangerously-allow-git if you really want stash to sync this folder.", + ]); + } +} + +async function readRegistry(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; + } + const entries = await readdir(stashPath, { withFileTypes: true }); + const records: RegistryEntry[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory() || entry.name.startsWith(".")) { continue; } - const cliValue = valuesFromCli[field.name]; - if (typeof cliValue === "string") { - values[field.name] = cliValue; - continue; + const recordPath = join(stashPath, entry.name, "stash.json"); + try { + const record = JSON.parse(await readFile(recordPath, "utf8")) as StashRecord; + records.push({ id: entry.name, record }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } } - values[field.name] = await promptField(field); } - return values; + return records; } -async function readBackgroundStatus(dir: string): Promise { - const statusPath = join(dir, ".stash", "status.json"); - if (!existsSync(statusPath)) { - return null; +async function readPersistedStatus(stashPath: string, id: string): Promise { + try { + return JSON.parse( + await readFile(join(getStashDirPath(id, stashPath), "status.json"), "utf8"), + ) as PersistedStatus; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; } - return JSON.parse(await readFile(statusPath, "utf8")) as PersistedBackgroundStatus; } -function renderGitWarning(stdout: NodeJS.WriteStream): void { - const { dim, yellow } = createColors(stdout); - writeLine( - stdout, - `${yellow("Warning:")} ${dim("This directory contains .git. Stash will not sync until you either:")}`, - ); - writeLine(stdout, dim(" - remove .git, or")); - writeLine( - stdout, - dim(' - run `stash config set allow-git true` (see "Using stash with git" in the README)'), - ); +function describeStatusKind(status: PersistedStatus | null): string { + if (status?.currentOperation) { + return status.currentOperation; + } + if (!status?.kind) { + return "never"; + } + if (status.kind === "checked" || status.kind === "synced") { + return "idle"; + } + return status.kind; } -async function warnIfGitSyncBlocked(dir: string, stdout: NodeJS.WriteStream): Promise { - if (!existsSync(join(dir, ".git"))) { - return; +function formatPendingChanges(status: PersistedStatus | null): string | null { + const pending = status?.pendingChanges; + if (!pending) { + return null; } - const localConfig = await readLocalConfig(dir); - if (localConfig["allow-git"] === true) { - return; + const parts: string[] = []; + if ((pending.modified ?? 0) > 0) { + parts.push(`${pending.modified} modified`); + } + if ((pending.added ?? 0) > 0) { + parts.push(`${pending.added} added`); + } + if ((pending.deleted ?? 0) > 0) { + parts.push(`${pending.deleted} deleted`); + } + return parts.length > 0 ? parts.join(", ") : null; +} + +function renderStatusTable( + entry: RegistryEntry, + status: PersistedStatus | null, + colors: Colors, +): string { + const table = new Table({ + style: { + head: [], + border: [], + compact: true, + }, + wordWrap: true, + }); + + table.push([ + colors.gray("Connection"), + entry.record.connections.map((connection) => colors.cyan(formatUri(connection))).join("\n"), + ]); + const statusKind = describeStatusKind(status); + table.push([colors.gray("State"), statusKind === "error" ? colors.red(statusKind) : statusKind]); + + if (status?.lastSync) { + table.push([colors.gray("Last sync"), colors.gray(formatTimeAgo(new Date(status.lastSync)))]); } - renderGitWarning(stdout); -} -function parseConfigValue(key: "allow-git", value: string): boolean { - if (value === "true") { - return true; + const pending = formatPendingChanges(status); + if (pending) { + table.push([colors.gray("Pending"), pending]); } - if (value === "false") { - return false; + + if (status?.error) { + table.push([colors.gray("Message"), colors.red(status.error)]); + } else if (status?.summary) { + table.push([colors.gray("Message"), status.summary]); } - throw new Error(`Invalid value for ${key}: expected true or false`); + + return table.toString(); } + async function runSetup( providerName: string, valuesFromCli: Record, - deps: Required>, - stdout: NodeJS.WriteStream, + deps: SetupDeps, + io: PromptIo, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { const provider = getProvider(providerName); - let globalConfig = await deps.readGlobalConfig(); - const values = await collectFields( - provider.spec.setup, - valuesFromCli, - getProviderConfig(globalConfig, providerName), - ); - globalConfig = setProviderConfig(globalConfig, providerName, values); - await deps.writeGlobalConfig(globalConfig); - writeLine(stdout, `Configured ${providerName}.`); + const globalFields = getGlobalConfigItems(provider); + const globalConfig = await deps.readGlobalConfig(); + const values = await collectFields(io, globalFields, valuesFromCli); + await deps.writeGlobalConfig(setProviderConfig(globalConfig, providerName, values)); + writeLine(stdout, formatConfigured(providerName, colors)); } async function runConnect( - providerName: string, - connectionName: string | undefined, + pathArg: string, + uri: string, valuesFromCli: Record, - deps: Required> & { - getService?: () => Promise; - }, - stdout: NodeJS.WriteStream, + deps: ConnectDeps, + io: PromptIo, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { - const { dim, green } = createColors(stdout); - const provider = getProvider(providerName); - const resolvedConnectionName = connectionName ?? providerName; - const dir = deps.cwd(); - await assertNewConnectionAllowedForDirectory(dir, resolvedConnectionName); + const targetPath = resolve(deps.cwd(), pathArg); + await mkdir(targetPath, { recursive: true }); + const canonicalPath = await canonicalizeExistingPath(targetPath); + ensureGitAllowed(canonicalPath, valuesFromCli.dangerouslyAllowGit === true); + const parsed = parseUri(uri); + const provider = getProvider(parsed.provider); let globalConfig = await deps.readGlobalConfig(); - const setupValues = await collectFields( - provider.spec.setup, + const existingGlobalValues = getProviderConfig(globalConfig, parsed.provider); + const globalValues = await collectFields( + io, + getGlobalConfigItems(provider), valuesFromCli, - getProviderConfig(globalConfig, providerName), + existingGlobalValues, ); - globalConfig = setProviderConfig(globalConfig, providerName, setupValues); - await deps.writeGlobalConfig(globalConfig); - - const stash = await Stash.init(dir, globalConfig); - const connectValues = await collectFields(provider.spec.connect, valuesFromCli); - await stash.connect({ - name: resolvedConnectionName, - provider: providerName, - ...connectValues, + + new provider({ + ...existingGlobalValues, + ...globalValues, + ...parsed.config, }); - globalConfig = await deps.readGlobalConfig(); - await deps.writeGlobalConfig(addBackgroundStash(globalConfig, resolve(dir))); - writeLine(stdout, `Connected ${resolvedConnectionName}.`); - await warnIfGitSyncBlocked(dir, stdout); + if (!sameStringRecord(globalValues, existingGlobalValues)) { + globalConfig = setProviderConfig(globalConfig, parsed.provider, globalValues); + await deps.writeGlobalConfig(globalConfig); + } + + const stashPath = deps.stashPath ?? getStashPath(); + const outcome = await withRegistryLock( + async () => { + const entries = await readRegistry(stashPath); + const found = entries.find((entry) => entry.record.path === canonicalPath); + const connection = { + provider: parsed.provider, + config: parsed.config, + }; - if (!deps.getService) { + if (!found) { + await Stash.create( + { + path: canonicalPath, + connections: [connection], + }, + globalConfig, + { stashPath }, + ); + return "connected" as const; + } + + if (found.record.connections.length === 0) { + await writeJsonFile(join(getStashDirPath(found.id, stashPath), "stash.json"), { + ...found.record, + connections: [connection], + } satisfies StashRecord); + return "connected" as const; + } + + if (found.record.connections.some((existing) => sameConnection(existing, connection))) { + return "already-connected" as const; + } + + const displayPath = shortenHomePath(found.record.path); + throw cliError(`${displayPath} is already connected to ${formatUri(found.record.connections[0])}.`, [ + `Multi-connection per stash is not yet supported. Run \`stash disconnect ${displayPath}\` first.`, + ]); + }, + { stashPath }, + ); + + if (outcome === "already-connected") { + writeLine(stdout, formatAlreadyConnected(uri, colors)); return; } + const displayPath = shortenHomePath(canonicalPath); + writeLine(stdout, formatConnected(uri, displayPath, colors)); + + let daemonRunning = false; try { - const status = await (await deps.getService()).status(); - if (status.running) { - writeLine( - stdout, - `${green("Background sync is on")} ${dim("·")} ${dim("This stash is now syncing automatically")}`, - ); - } + daemonRunning = (await (await deps.getService()).status()).running; } catch (error) { - if ( - error instanceof Error && - error.message === "Could not resolve a command to run `stash daemon`" - ) { - return; - } if (!isUnsupportedPlatformError(error)) { throw error; } } -} -function formatConnectionLabel(name: string, provider: string): string { - return `${name} (${provider})`; -} - -function validateDisconnectSelection( - name: string | undefined, - all: boolean, - targetPath: string | undefined, -): { name?: string; all: boolean; targetPath?: string } { - const selected = [name ? "name" : null, all ? "all" : null, targetPath ? "path" : null].filter( - (value): value is string => value !== null, - ); - if (selected.length === 0) { - throw new Error( - "argument required — run `stash disconnect `, `stash disconnect --all`, or `stash disconnect --path `", - ); - } - if (selected.length > 1) { - throw new Error( - "disconnect modes are mutually exclusive — use only one of ``, `--all`, or `--path `", - ); + if (daemonRunning) { + writeLine(stdout, formatConnectHint(colors)); } - return { name, all, targetPath }; -} - -function formatConnectionSummary(status: StatusResult): string { - const parts = formatChangeParts(status); - if (status.lastSync) { - const ago = formatTimeAgo(status.lastSync); - return parts.length > 0 - ? `Local changes: ${parts.join(", ")} · synced ${ago}` - : `Up to date · synced ${ago}`; - } - if (parts.length > 0) { - return `Local changes: ${parts.join(", ")} · Never synced`; - } - return "Waiting for first sync"; } async function runDisconnect( - name: string | undefined, - all: boolean, - targetPath: string | undefined, - cwd: () => string, - readConfig: () => Promise, - writeConfig: (config: GlobalConfig) => Promise, - stdout: NodeJS.WriteStream, + pathArg: string, + uri: string | undefined, + deps: DisconnectDeps, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { - const selection = validateDisconnectSelection(name, all, targetPath); - const globalConfig = await readConfig(); - if (selection.targetPath) { - const resolvedPath = resolve(selection.targetPath); - const wasRegistered = getBackgroundStashes(globalConfig).includes(resolvedPath); - if (!wasRegistered) { - writeLine(stdout, "No stash registered at that path."); - return; - } - await writeConfig(removeBackgroundStash(globalConfig, resolvedPath)); - if (existsSync(resolvedPath)) { - await rm(join(resolvedPath, ".stash"), { recursive: true, force: true }); - } - writeLine(stdout, "Disconnected stash."); - return; - } - - const dir = cwd(); - const stash = await Stash.load(dir, globalConfig); - - if (selection.all) { - for (const name of Object.keys(stash.connections)) { - await stash.disconnect(name); - } - await writeConfig(removeBackgroundStash(globalConfig, resolve(dir))); - await rm(join(dir, ".stash"), { recursive: true, force: true }); - writeLine(stdout, "Disconnected stash."); - return; - } - - const connectionName = selection.name!; - if (!stash.connections[connectionName]) { - throw new Error(`Connection not found: ${connectionName}`); - } + const lookupPath = await resolveLookupPath(resolve(deps.cwd(), pathArg)); + const stashPath = deps.stashPath ?? getStashPath(); + + let disconnectedPath = lookupPath; + let disconnectedUri = uri ?? ""; + + await withRegistryLock( + async () => { + const entries = await readRegistry(stashPath); + const found = entries.find((entry) => entry.record.path === lookupPath); + if (!found) { + throw cliError(`No stash found at ${shortenHomePath(lookupPath)}.`); + } - await stash.disconnect(connectionName); - if (Object.keys(stash.connections).length === 0) { - await writeConfig(removeBackgroundStash(globalConfig, resolve(dir))); - await rm(join(dir, ".stash"), { recursive: true, force: true }); - } - writeLine(stdout, `Disconnected ${connectionName}.`); -} + const currentUris = found.record.connections.map((connection) => formatUri(connection)); + const displayPath = shortenHomePath(found.record.path); + let targetUri = uri; + if (!targetUri) { + if (currentUris.length === 0) { + throw cliError(`No active connections at ${displayPath}.`); + } + if (currentUris.length !== 1) { + throw cliError(`Multiple connections at ${displayPath}:`, [ + ...currentUris.map((currentUri) => ` ${currentUri}`), + `Pass the URI to disconnect: stash disconnect ${displayPath} `, + ]); + } + [targetUri] = currentUris; + } -async function runSync(cwd: () => string, readConfig: () => Promise): Promise { - const stash = await Stash.load(cwd(), await readConfig()); - const line = new LiveLine(process.stdout); - const { green, red } = line.colors; - const renderer = new SyncRenderer(line); - const subscription = stash.on("mutation", (mutation) => { - renderer.onMutation(mutation); - }); + const nextConnections = found.record.connections.filter( + (connection) => formatUri(connection) !== targetUri, + ); + if (nextConnections.length === found.record.connections.length) { + throw cliError(`Connection not found: ${targetUri}.`); + } - line.startSpinner("checking..."); + disconnectedPath = found.record.path; + disconnectedUri = targetUri; + await writeJsonFile(join(getStashDirPath(found.id, stashPath), "stash.json"), { + ...found.record, + connections: nextConnections, + } satisfies StashRecord); + }, + { stashPath }, + ); - try { - await stash.sync(); - const summary = renderer.done(); - line.print(summary ? `${green("✓")} synced (${summary})` : `${green("✓")} up to date`); - } catch (error) { - renderer.error(error as Error); - line.print( - `${red("✗")} sync failed: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; - } finally { - subscription.dispose(); - renderer.dispose(); - line.dispose(); - } + writeLine(stdout, formatDisconnected(disconnectedUri, shortenHomePath(disconnectedPath), colors)); } -async function runWatch(cwd: () => string, readConfig: () => Promise): Promise { - const dir = cwd(); - const stash = await Stash.load(dir, await readConfig()); - if (Object.keys(stash.connections).length === 0) { - throw new Error("no connection configured — run `stash connect ` first"); +async function runRelink( + oldPath: string, + newPath: string, + dangerouslyAllowGit: boolean, + deps: Pick, + stdout: NodeJS.WritableStream, + colors: Colors, +): Promise { + const oldCanonical = await resolveLookupPath(oldPath); + const newCanonical = await canonicalizeExistingPath(newPath); + ensureGitAllowed(newCanonical, dangerouslyAllowGit); + const displayOld = shortenHomePath(oldCanonical); + const displayNew = shortenHomePath(newCanonical); + + if (oldCanonical === newCanonical) { + writeLine(stdout, formatAlreadyLinked(displayNew, colors)); + return; } - await watchStash(stash, { dir, stdin: process.stdin, stdout: process.stdout }); -} -function formatChangeParts(status: { - added: string[]; - modified: string[]; - deleted: string[]; -}): string[] { - const parts: string[] = []; - if (status.added.length > 0) parts.push(`${status.added.length} added`); - if (status.modified.length > 0) parts.push(`${status.modified.length} modified`); - if (status.deleted.length > 0) parts.push(`${status.deleted.length} deleted`); - return parts; -} + const stashPath = deps.stashPath ?? getStashPath(); + await withRegistryLock( + async () => { + const entries = await readRegistry(stashPath); + const current = entries.find((entry) => entry.record.path === oldCanonical); + if (!current) { + throw cliError(`No stash found at ${displayOld}.`); + } + if (entries.some((entry) => entry.id !== current.id && entry.record.path === newCanonical)) { + throw cliError(`A stash already exists at ${displayNew}.`); + } + await writeJsonFile(join(getStashDirPath(current.id, stashPath), "stash.json"), { + ...current.record, + path: newCanonical, + } satisfies StashRecord); + }, + { stashPath }, + ); -function formatStashCount(count: number): string { - return `${count} ${count === 1 ? "stash" : "stashes"}`; + writeLine(stdout, formatRelinked(displayOld, displayNew, colors)); } -async function runStatusAll( - readConfig: () => Promise, - serviceHandle: ServiceHandle, - stdout: NodeJS.WriteStream, +async function runStatus( + deps: StatusDeps, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { - const { dim, green, red } = createColors(stdout); - const globalConfig = await readConfig(); - const stashes = getBackgroundStashes(globalConfig); - - if (stashes.length === 0) { - writeLine( - stdout, - "No stashes connected yet — run `stash connect ` in a directory to get started", - ); + const daemonState = await deps + .getService() + .then((service) => service.status()) + .then((status) => (status.running ? "running" : "stopped")) + .catch((error: unknown) => (isUnsupportedPlatformError(error) ? "unsupported" : "error")); + + writeLine(stdout, formatDaemonState(daemonState, colors)); + writeBlankLine(stdout); + + const stashPath = deps.stashPath ?? getStashPath(); + const entries = (await readRegistry(stashPath)) + .filter((entry) => entry.record.connections.length > 0) + .sort((a, b) => a.record.path.localeCompare(b.record.path)); + + if (entries.length === 0) { + writeLine(stdout, "No stashes registered. Run `stash connect ` to add one (e.g. `stash connect . github:owner/repo`)."); return; } - try { - const current = await serviceHandle.status(); - if (current.running) { - writeLine( - stdout, - `${green("Background sync is on")} ${dim("·")} ${dim(`watching ${formatStashCount(stashes.length)}`)}`, - ); - } else { - writeLine(stdout, red("Background sync is off")); - writeLine( - stdout, - dim(`Run \`stash start\` to resume syncing ${formatStashCount(stashes.length)}`), - ); - } - } catch (error) { - if (isUnsupportedPlatformError(error)) { - writeLine(stdout, red("Background sync is not supported on this platform")); - } else { - throw error; - } - } - - writeLine(stdout, ""); - - for (const dir of stashes) { - const title = basename(dir); - if (!existsSync(dir)) { - writeLine(stdout, `${red("✗")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - writeLine(stdout, dim(" Directory not found")); - writeLine(stdout, ""); - continue; + for (const [index, entry] of entries.entries()) { + if (index > 0) { + writeBlankLine(stdout); } - - if (!isStashDirectory(dir)) { - writeLine(stdout, `${red("✗")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - writeLine(stdout, dim(" Not a stash")); - writeLine(stdout, ""); + const displayPath = shortenHomePath(entry.record.path); + const status = await readPersistedStatus(stashPath, entry.id); + if (status?.kind === "missing" || !existsSync(entry.record.path)) { + writeLine(stdout, `${displayPath} ${colors.yellow("(path missing)")}`); + writeLine(stdout, colors.gray(`Run: stash relink ${displayPath} `)); continue; } - const stash = await Stash.load(dir, globalConfig); - const persistedStatus = await readBackgroundStatus(dir); - const localStatus = stash.status(); - const connectionNames = Object.keys(stash.connections); - - if (persistedStatus?.kind === "error") { - writeLine(stdout, `${red("✗")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - for (const connectionName of connectionNames) { - const connection = stash.connections[connectionName]; - writeLine( - stdout, - ` ${formatConnectionLabel(connectionName, connection.provider)} ${dim("·")} ${red(persistedStatus.error ?? "unknown error")}`, - ); - } - writeLine(stdout, ""); - continue; - } - - writeLine(stdout, `${green("●")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - - for (const connectionName of connectionNames) { - const connection = stash.connections[connectionName]; - if (persistedStatus === null || localStatus.lastSync === null) { - writeLine( - stdout, - ` ${formatConnectionLabel(connectionName, connection.provider)} ${dim("·")} ${dim("Waiting for first sync")}`, - ); - continue; - } - - writeLine( - stdout, - ` ${formatConnectionLabel(connectionName, connection.provider)} ${dim("·")} ${dim(formatConnectionSummary(localStatus))}`, - ); - } - writeLine(stdout, ""); + writeLine(stdout, displayPath); + writeLine(stdout, renderStatusTable(entry, status, colors)); } + + writeBlankLine(stdout); + writeLine(stdout, pluralize(entries.length, "stash")); } async function runStart( serviceHandle: ServiceHandle, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { - const { dim, green, red } = createColors(stdout); try { - const current = await serviceHandle.status(); - if (current.installed && current.running) { - writeLine(stdout, green("Background sync is already running")); - return; - } + await serviceHandle.install(); } catch (error) { if (isUnsupportedPlatformError(error)) { - writeLine(stdout, red("Background sync is not supported on this platform")); - process.exitCode = 1; - return; + throw cliError("This platform does not support background services yet."); } throw error; } - await serviceHandle.install(); - const count = getBackgroundStashes(await readConfig()).length; - writeLine(stdout, green("Background sync is on")); - writeLine(stdout, dim(`Watching ${formatStashCount(count)} · starts on startup`)); + writeLine(stdout, `Daemon installed and ${colors.green("running")}`); } async function runStop( serviceHandle: ServiceHandle, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, + stdout: NodeJS.WritableStream, ): Promise { - const { dim, red } = createColors(stdout); try { - const current = await serviceHandle.status(); - if (!current.installed && !current.running) { - writeLine(stdout, red("Background sync is not running")); - return; + const status = await serviceHandle.status(); + if (!status.running) { + throw cliError("Daemon is not running."); } + await serviceHandle.uninstall(); } catch (error) { if (isUnsupportedPlatformError(error)) { - writeLine(stdout, red("Background sync is not running")); - return; + throw cliError("This platform does not support background services yet."); } throw error; } - - await serviceHandle.uninstall(); - const count = getBackgroundStashes(await readConfig()).length; - writeLine(stdout, red("Background sync is off")); - writeLine(stdout, dim(`Run \`stash start\` to resume syncing ${formatStashCount(count)}`)); -} - -async function ensureInitializedStash( - cwd: () => string, - readConfig: () => Promise, -): Promise { - const dir = cwd(); - await Stash.load(dir, await readConfig()); - return dir; -} - -async function runConfigSet( - key: string, - value: string, - cwd: () => string, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, -): Promise { - if (!isStashConfigKey(key)) { - throw new Error(`Unknown config key: ${key}`); - } - const dir = await ensureInitializedStash(cwd, readConfig); - const currentConfig = await readLocalConfig(dir); - const nextConfig: LocalConfig = { - ...currentConfig, - [key]: parseConfigValue(key, value), - }; - await writeLocalConfig(dir, nextConfig); - writeLine(stdout, `${key}=${String(nextConfig[key])}`); -} - -async function runConfigGet( - key: string, - cwd: () => string, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, -): Promise { - if (!isStashConfigKey(key)) { - throw new Error(`Unknown config key: ${key}`); - } - const dir = await ensureInitializedStash(cwd, readConfig); - const localConfig = await readLocalConfig(dir); - const value = localConfig[key]; - writeLine(stdout, value === undefined ? "" : String(value)); -} - -function addFieldOptions(command: Command, fields: Field[]): void { - for (const field of fields) { - command.addOption(new Option(`--${field.name} `, field.label)); - } -} - -async function createDefaultService(argv = process.argv): Promise { - const launch = await resolveServiceLaunch(argv); - return new Daemon({ - name: SERVICE_NAME, - description: SERVICE_DESCRIPTION, - command: launch.command, - args: launch.args, - env: process.env.PATH ? { PATH: process.env.PATH } : undefined, - }); + writeLine(stdout, "Daemon stopped"); } -export async function main(argv = process.argv, deps: CliDependencies = {}): Promise { +export async function main(argv = process.argv, deps: CliDependencies = {}): Promise { const cwd = deps.cwd ?? (() => process.cwd()); + const input = deps.stdin ?? process.stdin; const readConfig = deps.readGlobalConfig ?? readGlobalConfig; const writeConfig = deps.writeGlobalConfig ?? writeGlobalConfig; + const stashPath = deps.stashPath ?? getStashPath(); const serviceHandle = deps.service; const getService = async () => serviceHandle ?? (await createDefaultService(argv)); const runDaemonCommand = deps.runDaemon ?? runDaemon; const stdout = deps.stdout ?? process.stdout; const stderr = deps.stderr ?? process.stderr; + const colors = createColors(Boolean(stdout.isTTY)); + const { version } = createRequire(import.meta.url)("../package.json") as { version: string }; + let exitCode = 0; - const dirsToCheck = [cwd()]; - try { - const config = await readConfig(); - dirsToCheck.push(...getBackgroundStashes(config)); - } catch { - // Global config unreadable — skip migration check. - } - let daemonBounced = false; - for (const dir of dirsToCheck) { - if (await bounceDaemonIfMigrationNeeded(dir, getService)) { - daemonBounced = true; - break; + async function runCommand(action: () => Promise): Promise { + try { + await action(); + writeBlankLine(stdout); + } catch (error) { + exitCode = 1; + renderCliError(stderr, toCliCommandError(error), colors); + writeBlankLine(stderr); } } - const { version } = createRequire(import.meta.url)("../package.json") as { version: string }; - const program = new Command() .name("stash") .description("Conflict-free synced folders") @@ -734,103 +855,122 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro const setupCommand = program .command("setup") - .description("Configure global provider settings") + .description("Configure provider settings") .argument("", "Provider name") .allowUnknownOption(true) .action(async (providerName: string, _opts: unknown, command: Command) => { - await runSetup( - providerName, - command.opts() as Record, - { readGlobalConfig: readConfig, writeGlobalConfig: writeConfig }, - stdout, - ); + await runCommand(async () => { + await runSetup( + providerName, + command.opts() as Record, + { readGlobalConfig: readConfig, writeGlobalConfig: writeConfig }, + { input, output: stdout }, + stdout, + colors, + ); + }); }); const connectCommand = program .command("connect") - .description("Connect this stash to a provider") - .argument("", "Provider name") - .argument("[name]", "Connection name") + .description("Connect a folder to a provider URI") + .argument("", "Folder to connect (created if it does not exist)") + .argument("", "Provider URI (e.g. github:owner/repo)") .allowUnknownOption(true) - .action( - async ( - providerName: string, - connectionName: string | undefined, - _opts: unknown, - command: Command, - ) => { - const opts = command.opts() as Record; + .option("--dangerously-allow-git", "Allow syncing a git repository") + .addHelpText( + "after", + "\nExamples:\n" + + " $ stash connect . github:owner/repo # current directory\n" + + " $ stash connect ~/Notes github:owner/notes # existing folder\n" + + " $ stash connect ./inbox github:owner/inbox # creates ./inbox if missing\n", + ) + .action(async (pathArg: string, uri: string, _opts: unknown, command: Command) => { + await runCommand(async () => { await runConnect( - providerName, - connectionName, - opts, + pathArg, + uri, + command.opts() as Record, { cwd, readGlobalConfig: readConfig, writeGlobalConfig: writeConfig, + stashPath, getService, }, + { input, output: stdout }, stdout, + colors, ); - }, - ); + }); + }); program .command("disconnect") - .description("Disconnect a named connection or stash") - .argument("[name]", "Connection name") - .option("--all", "Disconnect all connections in the current stash") - .option("--path ", "Disconnect a stash by path") - .action(async (name: string | undefined, options: { all?: boolean; path?: string }) => { - await runDisconnect( - name, - options.all === true, - options.path, - cwd, - readConfig, - writeConfig, - stdout, - ); + .description("Disconnect a stash") + .argument("", "Folder to disconnect") + .argument("[uri]", "Connection URI to drop (required when the stash has multiple connections)") + .addHelpText( + "after", + "\nExamples:\n" + + " $ stash disconnect . # current directory\n" + + " $ stash disconnect ~/Notes # only connection\n" + + " $ stash disconnect ~/Notes github:owner/notes # explicit URI\n", + ) + .action(async (pathArg: string, uri: string | undefined) => { + await runCommand(async () => { + await runDisconnect(pathArg, uri, { cwd, stashPath }, stdout, colors); + }); }); + program - .command("sync") - .description("Sync local files with connections") - .action(() => runSync(cwd, readConfig)); - program - .command("watch") - .description("Watch and sync continuously") - .action(() => runWatch(cwd, readConfig)); + .command("relink") + .description("Move a stash to a new path (use after renaming or moving the folder)") + .argument("", "Current path of the stash (may no longer exist)") + .argument("", "New path of the folder") + .option("--dangerously-allow-git", "Allow relinking to a git repository") + .action(async (oldPath: string, newPath: string, options: { dangerouslyAllowGit?: boolean }) => { + await runCommand(async () => { + await runRelink( + oldPath, + newPath, + options.dangerouslyAllowGit === true, + { stashPath }, + stdout, + colors, + ); + }); + }); + program .command("status") - .description("Show all connected stashes") + .description("Show registered stashes") .action(async () => { - await runStatusAll(readConfig, await getService(), stdout); + await runCommand(async () => { + await runStatus( + { + stashPath, + getService, + }, + stdout, + colors, + ); + }); }); - const configCommand = program.command("config").description("Manage stash config"); - - configCommand - .command("set") - .description("Set a stash config value") - .argument("", "Config key") - .argument("", "Config value") - .action((key: string, value: string) => runConfigSet(key, value, cwd, readConfig, stdout)); - - configCommand - .command("get") - .description("Get a stash config value") - .argument("", "Config key") - .action((key: string) => runConfigGet(key, cwd, readConfig, stdout)); - program .command("start") .description("Start background sync") - .action(async () => runStart(await getService(), readConfig, stdout)); + .action(async () => + runCommand(async () => runStart(await getService(), stdout, colors)), + ); program .command("stop") .description("Stop background sync") - .action(async () => runStop(await getService(), readConfig, stdout)); + .action(async () => + runCommand(async () => runStop(await getService(), stdout)), + ); program .command("daemon", { hidden: true }) @@ -838,31 +978,34 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro .action(() => runDaemonCommand()); const commandName = argv[2]; - const providerName = argv[3]; - if (providerName && (commandName === "setup" || commandName === "connect")) { - const provider = providers[providerName]; - if (provider) { - if (commandName === "setup") { - addFieldOptions(setupCommand, provider.spec.setup); - } else { - addFieldOptions(connectCommand, [...provider.spec.setup, ...provider.spec.connect]); + const providerToken = + commandName === "connect" && argv[4] && !argv[3]?.includes(":") ? argv[4] : argv[3]; + if (providerToken && (commandName === "setup" || commandName === "connect")) { + try { + const uriProvider = + commandName === "connect" && providerToken.includes(":") + ? parseUri(providerToken).provider + : providerToken; + const provider = providers[uriProvider]; + if (provider) { + if (commandName === "setup") { + addFieldOptions(setupCommand, getGlobalConfigItems(provider)); + } else { + addFieldOptions(connectCommand, getGlobalConfigItems(provider)); + } + } + } catch (error) { + if (!(error instanceof InvalidUriError)) { + throw error; } } } if (argv.length <= 2) { program.outputHelp(); - return; + return exitCode; } await program.parseAsync(argv); - - const skipRestart = commandName === "stop" || commandName === "daemon"; - if (daemonBounced && !skipRestart) { - try { - await (await getService()).install(); - } catch { - // Best-effort restart — service may be unsupported or unavailable. - } - } + return exitCode; } diff --git a/src/cli.ts b/src/cli.ts old mode 100755 new mode 100644 index 63029a4..181e649 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,11 @@ #!/usr/bin/env node -import { CliDisplayError } from "./cli-display-error.ts"; import { main } from "./cli-main.ts"; -main().catch((error) => { - if (error instanceof CliDisplayError) { - error.writeTo(process.stderr); - } else { +main() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((error) => { console.error(error instanceof Error ? error.message : String(error)); - } - process.exitCode = 1; -}); + process.exitCode = 1; + }); diff --git a/src/daemon.ts b/src/daemon.ts index e168624..9ad0524 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,16 +1,25 @@ import watcher from "@parcel/watcher"; import { existsSync } from "node:fs"; -import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { getGlobalConfigPath, normalizeGlobalConfig, readGlobalConfig } from "./global-config.ts"; +import { + getGlobalConfigPath, + getProviderConfig, + getStashDirPath, + normalizeGlobalConfig, +} from "./global-config.ts"; +import { withRegistryLock } from "./registry-lock.ts"; import { Stash } from "./stash.ts"; -import type { GlobalConfig } from "./types.ts"; +import type { GlobalConfig, StashRecord } from "./types.ts"; import { Watch, type WatchStatus } from "./watch.ts"; +import { readJsonFile, writeJsonFile } from "./utils/fs.ts"; const MAX_LOG_BYTES = 1_000_000; +const DEFAULT_POLL_MS = 30_000; +const ORPHAN_MAX_AGE_MS = 5 * 60_000; type SubscribeFn = typeof watcher.subscribe; -type ConfigSubscription = Awaited>; +type WatchSubscription = Awaited>; type RunningWatch = { start(): Promise; @@ -18,8 +27,7 @@ type RunningWatch = { }; type CreateWatchArgs = { - dir: string; - globalConfig: GlobalConfig; + stash: Stash; onStatus: (status: WatchStatus) => Promise; }; @@ -31,19 +39,42 @@ type BackgroundDaemonOptions = { subscribe?: SubscribeFn; createWatch?: CreateWatchFn; log?: (message: string) => void; + pollMs?: number; }; -type PersistedWatchStatus = { - kind: WatchStatus["kind"]; +type PersistedStatus = { + kind: string; lastSync: string | null; summary: string | null; error: string | null; }; +type DesiredEntry = { + id: string; + record: StashRecord; + providerConfig: Record>; + key: string; +}; + +type RegistrySnapshot = { + live: Map; + missing: Map; + tombstones: Set; + orphans: Set; +}; + +type RunningEntry = { + key: string; + watch: RunningWatch; +}; + +async function readGlobalConfigAtPath(configPath: string): Promise { + return normalizeGlobalConfig(await readJsonFile(configPath, {})); +} + async function defaultCreateWatch(args: CreateWatchArgs): Promise { - const stash = await Stash.load(args.dir, args.globalConfig); - const watch = new Watch(stash, { - dir: args.dir, + const watch = new Watch(args.stash, { + dir: args.stash.record.path, onStatus: (status) => { void args.onStatus(status); }, @@ -58,7 +89,7 @@ async function defaultCreateWatch(args: CreateWatchArgs): Promise }; } -function serializeStatus(status: WatchStatus): PersistedWatchStatus { +function serializeStatus(status: WatchStatus): PersistedStatus { if (status.kind === "synced") { return { kind: status.kind, @@ -118,24 +149,38 @@ async function appendCappedLog(path: string, line: string): Promise { await writeFile(path, lines.join("\n").replace(/^\n+/, ""), "utf8"); } +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + export class BackgroundDaemon { private readonly configPath: string; + private readonly stashPath: string; private readonly readGlobalConfigFn: () => Promise; private readonly subscribeFn: SubscribeFn; private readonly createWatchFn: CreateWatchFn; private readonly log: (message: string) => void; - private readonly running = new Map(); - private configSubscription: ConfigSubscription | null = null; + private readonly pollMs: number; + private readonly running = new Map(); + private readonly subscriptions: WatchSubscription[] = []; private started = false; private stopping = false; private reloadPromise: Promise | null = null; + private pendingReload = false; + private pollTimer: NodeJS.Timeout | null = null; constructor(options: BackgroundDaemonOptions = {}) { this.configPath = options.configPath ?? getGlobalConfigPath(); - this.readGlobalConfigFn = options.readGlobalConfig ?? readGlobalConfig; + this.stashPath = dirname(this.configPath); + this.readGlobalConfigFn = options.readGlobalConfig ?? (() => readGlobalConfigAtPath(this.configPath)); this.subscribeFn = options.subscribe ?? watcher.subscribe; this.createWatchFn = options.createWatch ?? defaultCreateWatch; this.log = options.log ?? ((message) => console.log(message)); + this.pollMs = options.pollMs ?? DEFAULT_POLL_MS; } get watchCount(): number { @@ -148,28 +193,11 @@ export class BackgroundDaemon { } this.started = true; - await mkdir(dirname(this.configPath), { recursive: true }); - try { - this.configSubscription = await this.subscribeFn( - dirname(this.configPath), - (error, events) => { - if (error) { - this.log(`background daemon config watch failed: ${error.message}`); - return; - } - if (!events.some((event) => event.path === this.configPath)) { - return; - } - void this.reload(); - }, - ); - } catch (error) { - this.log( - `native config watcher unavailable, config hot-reload disabled: ${error instanceof Error ? error.message : String(error)}`, - ); - } - + await mkdir(this.stashPath, { recursive: true }); + await this.installWatchers(); + await this.runStartupGc(); await this.reload(); + this.schedulePoll(); } async stop(): Promise { @@ -178,9 +206,10 @@ export class BackgroundDaemon { } this.stopping = true; - if (this.configSubscription) { - await this.configSubscription.unsubscribe(); - this.configSubscription = null; + this.clearPollTimer(); + while (this.subscriptions.length > 0) { + const subscription = this.subscriptions.pop(); + await subscription?.unsubscribe(); } if (this.reloadPromise) { await this.reloadPromise; @@ -188,48 +217,106 @@ export class BackgroundDaemon { const entries = [...this.running.entries()]; this.running.clear(); - await Promise.all(entries.map(async ([, watchHandle]) => watchHandle.stop())); + await Promise.all(entries.map(async ([, entry]) => entry.watch.stop())); + } + + private schedulePoll(): void { + this.clearPollTimer(); + this.pollTimer = setTimeout(() => { + this.pollTimer = null; + if (this.stopping) { + return; + } + void this.reload().finally(() => { + if (!this.stopping) { + this.schedulePoll(); + } + }); + }, this.pollMs); + } + + private clearPollTimer(): void { + if (!this.pollTimer) { + return; + } + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + + private async installWatchers(): Promise { + for (const watchedPath of [this.configPath, this.stashPath]) { + try { + const subscription = await this.subscribeFn(watchedPath, (error, events) => { + if (error) { + this.log(`background daemon watcher failed: ${error.message}`); + return; + } + if ( + watchedPath === this.configPath && + !events.some((event) => event.path === this.configPath) + ) { + return; + } + void this.reload(); + }); + this.subscriptions.push(subscription); + } catch (error) { + this.log( + `native watcher unavailable, using poll-only reconciliation: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } } private async reload(): Promise { if (this.reloadPromise) { + this.pendingReload = true; return this.reloadPromise; } this.reloadPromise = (async () => { - const config = normalizeGlobalConfig(await this.readGlobalConfigFn()); - const desired = new Set(config.background.stashes); + do { + this.pendingReload = false; + + const globalConfig = normalizeGlobalConfig(await this.readGlobalConfigFn()); + const snapshot = await this.readRegistrySnapshot(globalConfig); + const retainedIds = new Set([ + ...snapshot.live.keys(), + ...snapshot.missing.keys(), + ...snapshot.tombstones, + ]); + + for (const id of snapshot.orphans) { + await this.deleteOrphanDirIfOld(id); + } - for (const [dir, watchHandle] of [...this.running.entries()]) { - if (desired.has(dir)) { - continue; + for (const id of snapshot.tombstones) { + await this.stopRunning(id); + await this.deleteTombstoneDir(id); } - this.running.delete(dir); - await watchHandle.stop(); - this.log(`stopped watching ${dir}`); - } - for (const dir of desired) { - if (this.running.has(dir)) { - continue; + for (const [id] of snapshot.missing) { + await this.stopRunning(id); + await this.persistMissingStatus(id); } - try { - const watchHandle = await this.createWatchFn({ - dir, - globalConfig: config, - onStatus: async (status) => { - await this.persistStatus(dir, status); - }, - }); - this.running.set(dir, watchHandle); - await watchHandle.start(); - this.log(`watching ${dir}`); - } catch (error) { - this.log( - `failed to start ${dir}: ${error instanceof Error ? error.message : String(error)}`, - ); + + for (const id of [...this.running.keys()]) { + if (!retainedIds.has(id)) { + await this.stopRunning(id); + } } - } + + for (const [id, entry] of snapshot.live) { + const current = this.running.get(id); + if (current && current.key === entry.key) { + continue; + } + if (current) { + await this.stopRunning(id); + } + await this.startWatch(entry, globalConfig); + } + } while (this.pendingReload && !this.stopping); })(); try { @@ -239,20 +326,171 @@ export class BackgroundDaemon { } } - private async persistStatus(dir: string, status: WatchStatus): Promise { - const stashDir = join(dir, ".stash"); - if (!existsSync(stashDir)) { + private async startWatch(entry: DesiredEntry, globalConfig: GlobalConfig): Promise { + try { + const stash = await Stash.load(entry.id, globalConfig, { stashPath: this.stashPath }); + const watch = await this.createWatchFn({ + stash, + onStatus: async (status) => { + await this.persistStatus(entry.id, status); + }, + }); + this.running.set(entry.id, { + key: entry.key, + watch, + }); + await watch.start(); + this.log(`watching ${entry.id}`); + } catch (error) { + this.log( + `failed to start ${entry.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async stopRunning(id: string): Promise { + const current = this.running.get(id); + if (!current) { return; } + this.running.delete(id); + await current.watch.stop(); + this.log(`stopped watching ${id}`); + } + + private async readRegistrySnapshot(globalConfig: GlobalConfig): Promise { + const live = new Map(); + const missing = new Map(); + const tombstones = new Set(); + const orphans = new Set(); + + for (const id of await this.listStashIds()) { + const record = await this.readStashRecordIfExists(id); + if (!record) { + orphans.add(id); + continue; + } + + if (record.connections.length === 0) { + tombstones.add(id); + continue; + } + + const providerConfig = Object.fromEntries( + [...new Set(record.connections.map((connection) => connection.provider))] + .sort((a, b) => a.localeCompare(b)) + .map((provider) => [provider, getProviderConfig(globalConfig, provider)]), + ); + const entry = { + id, + record, + providerConfig, + key: JSON.stringify({ + record, + providerConfig, + }), + } satisfies DesiredEntry; + + if (existsSync(record.path)) { + live.set(id, entry); + } else { + missing.set(id, entry); + } + } + + return { live, missing, tombstones, orphans }; + } + + private async listStashIds(): Promise { + if (!(await isDirectory(this.stashPath))) { + return []; + } + + const entries = await readdir(this.stashPath); + const ids: string[] = []; + for (const entry of entries.sort((a, b) => a.localeCompare(b))) { + if (entry.startsWith(".")) { + continue; + } + if (!(await isDirectory(join(this.stashPath, entry)))) { + continue; + } + ids.push(entry); + } + return ids; + } + private async readStashRecordIfExists(id: string): Promise { + const path = join(getStashDirPath(id, this.stashPath), "stash.json"); + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as StashRecord; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + } + + private async persistStatus(id: string, status: WatchStatus): Promise { + const stashDir = getStashDirPath(id, this.stashPath); await mkdir(stashDir, { recursive: true }); - await writeFile( - join(stashDir, "status.json"), - JSON.stringify(serializeStatus(status), null, 2), - "utf8", - ); + await writeJsonFile(join(stashDir, "status.json"), serializeStatus(status)); await appendCappedLog(join(stashDir, "sync.log"), formatLogLine(status)); } + + private async persistMissingStatus(id: string): Promise { + const stashDir = getStashDirPath(id, this.stashPath); + await mkdir(stashDir, { recursive: true }); + await writeJsonFile(join(stashDir, "status.json"), { + kind: "missing", + lastSync: null, + summary: null, + error: null, + } satisfies PersistedStatus); + } + + private async deleteTombstoneDir(id: string): Promise { + await withRegistryLock( + async () => { + const record = await this.readStashRecordIfExists(id); + if (record && record.connections.length === 0) { + await rm(getStashDirPath(id, this.stashPath), { recursive: true, force: true }); + } + }, + { stashPath: this.stashPath }, + ); + } + + private async deleteOrphanDirIfOld(id: string): Promise { + await withRegistryLock( + async () => { + const stashDir = getStashDirPath(id, this.stashPath); + if (!(await isDirectory(stashDir))) { + return; + } + if ((await this.readStashRecordIfExists(id)) !== null) { + return; + } + const ageMs = Date.now() - (await stat(stashDir)).mtimeMs; + if (ageMs > ORPHAN_MAX_AGE_MS) { + await rm(stashDir, { recursive: true, force: true }); + } + }, + { stashPath: this.stashPath }, + ); + } + + private async runStartupGc(): Promise { + const snapshot = await this.readRegistrySnapshot(normalizeGlobalConfig(await this.readGlobalConfigFn())); + for (const id of snapshot.orphans) { + await this.deleteOrphanDirIfOld(id); + } + for (const id of snapshot.tombstones) { + await this.deleteTombstoneDir(id); + } + } } export async function runDaemon(): Promise { diff --git a/src/errors.ts b/src/errors.ts index 8c691ce..1e6d9d0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,7 +14,7 @@ export class SyncLockError extends Error { export class GitRepoError extends Error { constructor( - message = "git repository detected — run `stash config set allow-git true` to allow syncing", + message = "git repository detected — pass `--dangerously-allow-git` when connecting or relinking this folder", ) { super(message); this.name = "GitRepoError"; @@ -23,7 +23,7 @@ export class GitRepoError extends Error { export class MultipleConnectionsError extends Error { constructor( - message = "Only one connection per stash — disconnect the existing connection before adding another (or fix .stash/config.json if it lists more than one).", + message = "Only one connection per stash — disconnect the existing connection before adding another (or fix the stash.json metadata if it lists more than one).", ) { super(message); this.name = "MultipleConnectionsError"; @@ -36,3 +36,10 @@ export class MigrationError extends Error { this.name = "MigrationError"; } } + +export class InvalidUriError extends Error { + constructor(uri: string, reason = "expected :") { + super(`Invalid URI: ${uri} (${reason})`); + this.name = "InvalidUriError"; + } +} diff --git a/src/global-config.ts b/src/global-config.ts index 347fccf..bf55380 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import type { GlobalConfig } from "./types.ts"; @@ -38,12 +39,42 @@ export function normalizeGlobalConfig(value: unknown): GlobalConfig { }; } -export function getGlobalConfigPath(): string { +export function getStashPath(): string { const xdg = process.env.XDG_CONFIG_HOME; if (xdg) { - return join(xdg, "stash", "config.json"); + return join(xdg, "stash"); } - return join(homedir(), ".stash", "config.json"); + return join(homedir(), ".stash"); +} + +export function getGlobalConfigPath(): string { + return join(getStashPath(), "config.json"); +} + +export function getStashDirPath(id: string, stashPath = getStashPath()): string { + return join(stashPath, id); +} + +export function getStashRecordPath(id: string, stashPath = getStashPath()): string { + return join(getStashDirPath(id, stashPath), "stash.json"); +} + +export async function ensureStashPath(stashPath = getStashPath()): Promise { + await mkdir(stashPath, { recursive: true, mode: 0o700 }); + return stashPath; +} + +export async function ensureStashDirPath(id: string, stashPath = getStashPath()): Promise { + const stashDir = getStashDirPath(id, stashPath); + await ensureStashPath(stashPath); + await mkdir(stashDir, { recursive: true, mode: 0o700 }); + return stashDir; +} + +function serializeGlobalConfig(config: GlobalConfig): Pick { + return { + providers: normalizeGlobalConfig(config).providers, + }; } export async function readGlobalConfig(): Promise { @@ -51,7 +82,8 @@ export async function readGlobalConfig(): Promise { } export async function writeGlobalConfig(config: GlobalConfig): Promise { - await writeJsonFile(getGlobalConfigPath(), normalizeGlobalConfig(config)); + await ensureStashPath(); + await writeJsonFile(getGlobalConfigPath(), serializeGlobalConfig(config), { mode: 0o600 }); } export function getBackgroundStashes(config: GlobalConfig): string[] { diff --git a/src/index.ts b/src/index.ts index 4976d1c..8e9d021 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ export { Stash } from "./stash.ts"; export type { StashEvents } from "./stash.ts"; export { GitHubProvider } from "./providers/github-provider.ts"; +export { parseUri } from "./uri.ts"; export { GitRepoError, + InvalidUriError, MigrationError, MultipleConnectionsError, PushConflictError, @@ -12,14 +14,13 @@ export type { Disposable } from "./emitter.ts"; export { getGlobalConfigPath, readGlobalConfig, writeGlobalConfig } from "./global-config.ts"; export type { ChangeSet, + ConfigItem, ConnectionConfig, - Field, FileMutation, FileState, GlobalConfig, Provider, ProviderClass, - ProviderSpec, PushPayload, SnapshotEntry, StatusResult, diff --git a/src/local-config.ts b/src/local-config.ts deleted file mode 100644 index 8f6ec45..0000000 --- a/src/local-config.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { join } from "node:path"; -import type { ConnectionConfig } from "./types.ts"; -import { readJsonFile, writeJsonFile } from "./utils/fs.ts"; - -export type LocalConfig = { - connections: Record; - "allow-git"?: boolean; -}; - -function asConnectionConfig(value: unknown): ConnectionConfig | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - const entries = Object.entries(value as Record).filter( - ([, fieldValue]) => typeof fieldValue === "string", - ); - const config = Object.fromEntries(entries) as Record; - return typeof config.provider === "string" ? (config as ConnectionConfig) : null; -} - -export function normalizeLocalConfig(value: unknown): LocalConfig { - const raw = value && typeof value === "object" ? (value as Record) : {}; - const connectionsValue = - raw.connections && typeof raw.connections === "object" && !Array.isArray(raw.connections) - ? (raw.connections as Record) - : {}; - const connections: Record = {}; - for (const [name, connectionConfig] of Object.entries(connectionsValue)) { - const normalized = asConnectionConfig(connectionConfig); - if (normalized) { - connections[name] = normalized; - } - } - - const allowGit = typeof raw["allow-git"] === "boolean" ? raw["allow-git"] : undefined; - return { - connections, - ...(allowGit === undefined ? {} : { "allow-git": allowGit }), - }; -} - -export function getLocalConfigPath(dir: string): string { - return join(dir, ".stash", "config.json"); -} - -export async function readLocalConfig(dir: string): Promise { - return normalizeLocalConfig(await readJsonFile(getLocalConfigPath(dir), { connections: {} })); -} - -export async function writeLocalConfig(dir: string, config: LocalConfig): Promise { - await writeJsonFile(getLocalConfigPath(dir), normalizeLocalConfig(config)); -} diff --git a/src/migrations.ts b/src/migrations.ts deleted file mode 100644 index beed15a..0000000 --- a/src/migrations.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { existsSync } from "node:fs"; -import { rename } from "node:fs/promises"; -import { join } from "node:path"; -import { MigrationError } from "./errors.ts"; -import { readJsonFile, writeJsonFile } from "./utils/fs.ts"; - -type Migration = { - name: string; - check(dir: string): Promise | boolean; - apply(dir: string): Promise; -}; - -const OLD_LOCAL_CONFIG_PATH = ".stash/config.local.json"; -const NEW_LOCAL_CONFIG_PATH = ".stash/config.json"; -const OLD_SNAPSHOT_DIR = ".stash/snapshot.local"; -const NEW_SNAPSHOT_DIR = ".stash/snapshot"; - -function resolvePath(dir: string, relativePath: string): string { - return join(dir, relativePath); -} - -function hasPath(dir: string, relativePath: string): boolean { - return existsSync(resolvePath(dir, relativePath)); -} - -const renameLocalMetadataLayoutMigration: Migration = { - name: "rename-local-metadata-layout", - check(dir: string): boolean { - return ( - hasPath(dir, OLD_LOCAL_CONFIG_PATH) || - hasPath(dir, OLD_SNAPSHOT_DIR) || - (hasPath(dir, OLD_LOCAL_CONFIG_PATH) && hasPath(dir, NEW_LOCAL_CONFIG_PATH)) || - (hasPath(dir, OLD_SNAPSHOT_DIR) && hasPath(dir, NEW_SNAPSHOT_DIR)) - ); - }, - async apply(dir: string): Promise { - const hasOldConfig: boolean = hasPath(dir, OLD_LOCAL_CONFIG_PATH); - const hasNewConfig: boolean = hasPath(dir, NEW_LOCAL_CONFIG_PATH); - if (hasOldConfig && hasNewConfig) { - throw new MigrationError( - `Both ${OLD_LOCAL_CONFIG_PATH} and ${NEW_LOCAL_CONFIG_PATH} exist. Resolve this manually before continuing.`, - ); - } - if (hasOldConfig) { - await rename( - resolvePath(dir, OLD_LOCAL_CONFIG_PATH), - resolvePath(dir, NEW_LOCAL_CONFIG_PATH), - ); - } - - const hasOldSnapshot: boolean = hasPath(dir, OLD_SNAPSHOT_DIR); - const hasNewSnapshot: boolean = hasPath(dir, NEW_SNAPSHOT_DIR); - if (hasOldSnapshot && hasNewSnapshot) { - throw new MigrationError( - `Both ${OLD_SNAPSHOT_DIR} and ${NEW_SNAPSHOT_DIR} exist. Resolve this manually before continuing.`, - ); - } - if (hasOldSnapshot) { - await rename(resolvePath(dir, OLD_SNAPSHOT_DIR), resolvePath(dir, NEW_SNAPSHOT_DIR)); - } - }, -}; - -const backfillConnectionProviderMigration: Migration = { - name: "backfill-connection-provider", - async check(dir: string): Promise { - if (!hasPath(dir, NEW_LOCAL_CONFIG_PATH)) { - return false; - } - const config = await readJsonFile(resolvePath(dir, NEW_LOCAL_CONFIG_PATH), {}); - const raw = - config && typeof config === "object" && !Array.isArray(config) - ? (config as Record) - : {}; - const connections = - raw.connections && typeof raw.connections === "object" && !Array.isArray(raw.connections) - ? (raw.connections as Record) - : {}; - return Object.values(connections).some( - (connection) => - !connection || - typeof connection !== "object" || - Array.isArray(connection) || - typeof (connection as Record).provider !== "string", - ); - }, - async apply(dir: string): Promise { - const path = resolvePath(dir, NEW_LOCAL_CONFIG_PATH); - const config = await readJsonFile(path, {}); - const raw = - config && typeof config === "object" && !Array.isArray(config) - ? ({ ...config } as Record) - : {}; - const rawConnections = - raw.connections && typeof raw.connections === "object" && !Array.isArray(raw.connections) - ? (raw.connections as Record) - : {}; - const connections: Record> = {}; - - for (const [name, connection] of Object.entries(rawConnections)) { - const record = - connection && typeof connection === "object" && !Array.isArray(connection) - ? ({ ...(connection as Record) } as Record) - : {}; - if (typeof record.provider !== "string") { - record.provider = name; - } - connections[name] = record; - } - - await writeJsonFile(path, { - ...raw, - connections, - }); - }, -}; - -const migrations: Migration[] = [ - renameLocalMetadataLayoutMigration, - backfillConnectionProviderMigration, -]; - -export async function needsMigration(dir: string): Promise { - for (const migration of migrations) { - if (await migration.check(dir)) { - return true; - } - } - return false; -} - -export async function ensureMigration(dir: string): Promise { - for (const migration of migrations) { - if (!(await migration.check(dir))) { - continue; - } - await migration.apply(dir); - } -} diff --git a/src/providers/github-provider.ts b/src/providers/github-provider.ts index e47e62f..f040f7d 100644 --- a/src/providers/github-provider.ts +++ b/src/providers/github-provider.ts @@ -4,7 +4,6 @@ import type { ChangeSet, FileState, Provider, - ProviderSpec, PushPayload, SnapshotEntry, } from "../types.ts"; @@ -35,16 +34,17 @@ async function streamToBuffer(stream: Readable): Promise { export interface GitHubConfig { token: string; - repo: string; + id?: string; + repo?: string; } type BlobResult = { isBinary: boolean; text: string | null } | null; export class GitHubProvider implements Provider { - static spec: ProviderSpec = { - setup: [{ name: "token", label: "Personal access token", secret: true }], - connect: [{ name: "repo", label: "Repository (user/repo)" }], - }; + static configSchema = [ + { name: "token", label: "Personal access token", secret: true, global: true }, + { name: "id", label: "Identifier (owner/repo)" }, + ]; private readonly token: string; private readonly owner: string; @@ -58,9 +58,10 @@ export class GitHubProvider implements Provider { if (!config.token) { throw new Error("Missing GitHub token"); } - const repoParts = config.repo.split("/"); + const id = config.id ?? config.repo ?? ""; + const repoParts = id.split("/"); if ( - !config.repo || + !id || repoParts.length !== 2 || repoParts[0].length === 0 || repoParts[1].length === 0 @@ -77,6 +78,11 @@ export class GitHubProvider implements Provider { async fetch(localSnapshot?: Record): Promise { const branchRes = await this.rest("GET", this.repoPath("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/branches/main")); if (branchRes.status === 404) { + const repoRes = await this.rest("GET", this.repoPath("")); + if (repoRes.status === 404) { + throw new Error(`Repository not found: ${this.owner}/${this.name}`); + } + await this.ensureOk(repoRes, "Failed to load repository"); this.headSha = undefined; this.baseTreeSha = undefined; return { added: new Map(), modified: new Map(), deleted: [] }; diff --git a/src/registry-lock.ts b/src/registry-lock.ts new file mode 100644 index 0000000..28d3b27 --- /dev/null +++ b/src/registry-lock.ts @@ -0,0 +1,183 @@ +import { readFileSync, unlinkSync } from "node:fs"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { hostname } from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; +import { getStashPath } from "./global-config.ts"; + +type RegistryLockPayload = { + pid?: number; + startedAt?: string; + hostname?: string; +}; + +type RegistryLockOptions = { + path?: string; + stashPath?: string; + retryDelayMs?: number; + staleMs?: number; +}; + +const DEFAULT_RETRY_DELAY_MS = 10; +const DEFAULT_STALE_MS = 60_000; +const SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; + +export function getRegistryLockPath(stashPath = getStashPath()): string { + return `${stashPath}/.lock`; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function readLockPayload(lockPath: string): Promise { + try { + const raw = await readFile(lockPath, "utf8"); + const value = JSON.parse(raw) as RegistryLockPayload; + return value && typeof value === "object" ? value : null; + } catch { + return null; + } +} + +async function isStaleLock(lockPath: string, staleMs: number): Promise { + let lockStat: Awaited>; + + try { + lockStat = await stat(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } + + const payload = await readLockPayload(lockPath); + if (typeof payload?.pid === "number" && !isProcessAlive(payload.pid)) { + return true; + } + const ageMs = Date.now() - lockStat.mtimeMs; + return ageMs > staleMs; +} + +function installCleanup(lockPath: string): () => void { + let cleaned = false; + const cleanup = (): void => { + if (cleaned) { + return; + } + cleaned = true; + try { + unlinkSync(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + }; + + const exitHandler = (): void => { + cleanup(); + }; + process.on("exit", exitHandler); + + const signalHandlers = new Map void>(); + for (const signal of SIGNALS) { + const handler = (): void => { + cleanup(); + process.off("exit", exitHandler); + for (const [registeredSignal, registeredHandler] of signalHandlers) { + process.off(registeredSignal, registeredHandler); + } + process.kill(process.pid, signal); + }; + signalHandlers.set(signal, handler); + process.once(signal, handler); + } + + return () => { + process.off("exit", exitHandler); + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + }; +} + +export async function withRegistryLock( + fn: () => Promise, + options: RegistryLockOptions = {}, +): Promise { + const stashPath = options.stashPath ?? getStashPath(); + const lockPath = options.path ?? getRegistryLockPath(stashPath); + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + const staleMs = options.staleMs ?? DEFAULT_STALE_MS; + const cleanupListeners = installCleanup(lockPath); + + await mkdir(stashPath, { recursive: true, mode: 0o700 }); + + const payload = JSON.stringify( + { + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: hostname(), + } satisfies RegistryLockPayload, + null, + 2, + ); + + let result: T | undefined; + let operationError: unknown; + let cleanupError: unknown; + let acquired = false; + + try { + for (;;) { + try { + await writeFile(lockPath, payload, { encoding: "utf8", flag: "wx", mode: 0o600 }); + acquired = true; + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + + if (await isStaleLock(lockPath, staleMs)) { + await rm(lockPath, { force: true }); + continue; + } + + await delay(retryDelayMs); + } + } + + result = await fn(); + } catch (error) { + operationError = error; + } finally { + if (acquired) { + try { + const currentPayload = readFileSync(lockPath, "utf8"); + if (currentPayload === payload) { + unlinkSync(lockPath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + cleanupError = error; + } + } + } + cleanupListeners(); + } + + if (operationError) { + throw operationError; + } + if (cleanupError) { + throw cleanupError; + } + return result as T; +} diff --git a/src/stash.ts b/src/stash.ts index 0707ab1..8ec6671 100644 --- a/src/stash.ts +++ b/src/stash.ts @@ -19,14 +19,17 @@ import { dirname, join, relative } from "node:path"; import { pipeline } from "node:stream/promises"; import { Emitter } from "./emitter.ts"; import { - GitRepoError, MultipleConnectionsError, PushConflictError, SyncLockError, } from "./errors.ts"; -import { normalizeGlobalConfig } from "./global-config.ts"; -import { getLocalConfigPath, readLocalConfig, writeLocalConfig } from "./local-config.ts"; -import { ensureMigration } from "./migrations.ts"; +import { + ensureStashDirPath, + getStashDirPath, + getStashRecordPath, + getStashPath, + normalizeGlobalConfig, +} from "./global-config.ts"; import { providers as defaultProviders } from "./providers/index.ts"; import type { ChangeSet, @@ -38,11 +41,14 @@ import type { ProviderClass, PushPayload, SnapshotEntry, + StashRecord, StatusResult, } from "./types.ts"; import { hashBuffer, hashText } from "./utils/hash.ts"; +import { writeJsonFile } from "./utils/fs.ts"; import { isTrackedPath } from "./utils/is-tracked-path.ts"; import { isValidText } from "./utils/text.ts"; +import { ulid } from "./utils/ulid.ts"; export type StashEvents = { mutation: FileMutation; @@ -50,6 +56,7 @@ export type StashEvents = { type StashOptions = { providers?: Record; + stashPath?: string; }; function cloneSnapshot(snapshot: Record): Record { @@ -65,60 +72,70 @@ const SYNC_RETRY_LIMIT = 5; const NON_FILE_HASH = "__non-file__"; export class Stash extends Emitter { + readonly id: string; + readonly record: StashRecord; private readonly dir: string; + private readonly metadataDir: string; + private readonly stashPath: string; private readonly globalConfig: GlobalConfig; private readonly providerRegistry: Record; private _connections: Record; private syncInFlight = false; private constructor( - dir: string, + id: string, + record: StashRecord, globalConfig: GlobalConfig, - connections: Record, options?: StashOptions, ) { super(); - this.dir = dir; + this.id = id; + this.record = { + path: record.path, + connections: record.connections.map((connection) => ({ + provider: connection.provider, + config: { ...connection.config }, + })), + }; + this.dir = record.path; + this.stashPath = options?.stashPath ?? getStashPath(); + this.metadataDir = getStashDirPath(id, this.stashPath); this.globalConfig = globalConfig; - this._connections = connections; + this._connections = this.connectionsFromRecord(this.record); this.providerRegistry = options?.providers ?? defaultProviders; } - static async load( - dir: string, - globalConfig: GlobalConfig, - options?: StashOptions, - ): Promise { - const stashDir = join(dir, ".stash"); - if (!existsSync(stashDir)) { - throw new Error( - "This directory is not a stash. Run `stash connect ` first.", - ); + static async load(id: string, globalConfig: GlobalConfig, options?: StashOptions): Promise { + const stashPath = options?.stashPath ?? getStashPath(); + const directRecordPath = getStashRecordPath(id, stashPath); + if (existsSync(directRecordPath)) { + const record = JSON.parse(readFileSync(directRecordPath, "utf8")) as StashRecord; + return new Stash(id, record, normalizeGlobalConfig(globalConfig), options); } - - await ensureMigration(dir); - const localConfig = await readLocalConfig(dir); - return new Stash( - dir, - normalizeGlobalConfig(globalConfig), - localConfig.connections ?? {}, - options, - ); + throw new Error(`Stash not found: ${id}`); } - static async init( - dir: string, + static async create( + record: StashRecord, globalConfig: GlobalConfig, options?: StashOptions, ): Promise { - const stashDir = join(dir, ".stash"); - mkdirSync(stashDir, { recursive: true }); - await ensureMigration(dir); - mkdirSync(join(stashDir, "snapshot"), { recursive: true }); - if (!existsSync(getLocalConfigPath(dir))) { - await writeLocalConfig(dir, { connections: {} }); - } - return Stash.load(dir, globalConfig, options); + const id = ulid(); + const stashPath = options?.stashPath ?? getStashPath(); + const metadataDir = getStashDirPath(id, stashPath); + await ensureStashDirPath(id, stashPath); + await writeJsonFile( + getStashRecordPath(id, stashPath), + { + path: record.path, + connections: record.connections.map((connection) => ({ + provider: connection.provider, + config: { ...connection.config }, + })), + } satisfies StashRecord, + ); + mkdirSync(join(metadataDir, "snapshot"), { recursive: true }); + return new Stash(id, record, normalizeGlobalConfig(globalConfig), options); } get connections(): Record { @@ -136,6 +153,32 @@ export class Stash extends Emitter { return out; } + private connectionsFromRecord(record: StashRecord): Record { + const connections: Record = {}; + for (const connection of record.connections) { + const name = connection.config.name ?? connection.provider; + const { name: _ignored, ...fields } = connection.config; + connections[name] = { + provider: connection.provider, + ...fields, + }; + } + return connections; + } + + private updateRecordConnections(): void { + this.record.connections = Object.entries(this._connections).map(([name, connection]) => { + const { provider, ...fields } = connection; + return { + provider, + config: { + name, + ...fields, + }, + }; + }); + } + async connect({ name, ...configFields }: { name: string } & ConnectionConfig): Promise { const { provider, ...fields } = configFields; const existing = Object.keys(this._connections); @@ -143,12 +186,12 @@ export class Stash extends Emitter { throw new MultipleConnectionsError(); } this._connections[name] = { provider, ...fields }; - await this.persistLocalConfig(); + await this.persistRecord(); } async disconnect(name: string): Promise { delete this._connections[name]; - await this.persistLocalConfig(); + await this.persistRecord(); } async sync(): Promise { @@ -159,11 +202,6 @@ export class Stash extends Emitter { this.syncInFlight = true; try { - const localConfig = await readLocalConfig(this.dir); - if (this.hasGitRepository() && localConfig["allow-git"] !== true) { - throw new GitRepoError(); - } - const connectionNames = Object.keys(this._connections); if (connectionNames.length === 0) { return; @@ -793,12 +831,9 @@ export class Stash extends Emitter { return readFileSync(snapshotPath, "utf8"); } - private async persistLocalConfig(): Promise { - const currentConfig = await readLocalConfig(this.dir); - await writeLocalConfig(this.dir, { - ...currentConfig, - connections: this._connections, - }); + private async persistRecord(): Promise { + this.updateRecordConnections(); + await writeJsonFile(getStashRecordPath(this.id, this.stashPath), this.record); } private acquireSyncLock(): void { @@ -930,18 +965,13 @@ export class Stash extends Emitter { } private snapshotPath(): string { - return join(this.dir, ".stash", "snapshot.json"); + return join(this.metadataDir, "snapshot.json"); } private snapshotLocalDir(): string { - return join(this.dir, ".stash", "snapshot"); + return join(this.metadataDir, "snapshot"); } - - private hasGitRepository(): boolean { - return existsSync(join(this.dir, ".git")); - } - private syncLockPath(): string { - return join(this.dir, ".stash", "sync.lock"); + return join(this.metadataDir, "sync.lock"); } } diff --git a/src/types.ts b/src/types.ts index 024d8b8..658759a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,24 @@ import type { Readable } from "node:stream"; -export interface ProviderSpec { - setup: Field[]; - connect: Field[]; -} - -export interface Field { +export interface ConfigItem { name: string; label: string; secret?: boolean; + global?: boolean; } export type SnapshotEntry = { hash: string } | { hash: string; modified: number }; +export interface StashConnectionRecord { + provider: string; + config: Record; +} + +export interface StashRecord { + path: string; + connections: StashConnectionRecord[]; +} + export type FileState = | { type: "text"; content: string } | { type: "binary"; hash: string; modified: number }; @@ -65,6 +71,6 @@ export interface GlobalConfig { } export type ProviderClass = { - spec: ProviderSpec; + configSchema: ConfigItem[]; new (config: Record): Provider; }; diff --git a/src/uri.ts b/src/uri.ts new file mode 100644 index 0000000..39a5933 --- /dev/null +++ b/src/uri.ts @@ -0,0 +1,22 @@ +import { InvalidUriError } from "./errors.ts"; + +export function parseUri(uri: string): { provider: string; config: { id: string } } { + const separator = uri.indexOf(":"); + if (separator < 0) { + throw new InvalidUriError(uri); + } + const provider = uri.slice(0, separator); + const id = uri.slice(separator + 1); + if (!provider) { + throw new InvalidUriError(uri, "missing provider before ':'"); + } + if (!id) { + throw new InvalidUriError(uri, "missing id after ':'"); + } + return { + provider, + config: { + id, + }, + }; +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 4d655ab..bd04442 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,19 +1,46 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const JSON_READ_RETRIES = 5; +const JSON_READ_RETRY_MS = 2; export async function readJsonFile(path: string, fallback: T): Promise { - try { - const raw = await readFile(path, "utf8"); - return JSON.parse(raw) as T; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return fallback; + for (let attempt = 0; attempt < JSON_READ_RETRIES; attempt += 1) { + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return fallback; + } + if (error instanceof SyntaxError && attempt < JSON_READ_RETRIES - 1) { + await delay(JSON_READ_RETRY_MS); + continue; + } + throw error; } - throw error; } + + return fallback; } -export async function writeJsonFile(path: string, value: unknown): Promise { +export async function writeJsonFile( + path: string, + value: unknown, + options?: { mode?: number }, +): Promise { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(value, null, 2), "utf8"); + const tempPath = `${path}.tmp`; + + try { + await writeFile(tempPath, JSON.stringify(value, null, 2), "utf8"); + if (options?.mode !== undefined) { + await chmod(tempPath, options.mode); + } + await rename(tempPath, path); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } } diff --git a/src/utils/ulid.ts b/src/utils/ulid.ts new file mode 100644 index 0000000..febf0e2 --- /dev/null +++ b/src/utils/ulid.ts @@ -0,0 +1,39 @@ +import { randomBytes } from "node:crypto"; + +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +function encodeBase32(value: number, length: number): string { + let out = ""; + for (let i = 0; i < length; i += 1) { + out = ENCODING[value % 32] + out; + value = Math.floor(value / 32); + } + return out; +} + +function encodeBytes(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ""; + + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += ENCODING[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + out += ENCODING[(value << (5 - bits)) & 31]; + } + + return out; +} + +export function ulid(): string { + const time = encodeBase32(Date.now(), 10); + const randomness = encodeBytes(randomBytes(10)).slice(0, 16); + return `${time}${randomness}`; +} diff --git a/tests/e2e/stash-github-scenarios.e2e.test.ts b/tests/e2e/stash-github-scenarios.e2e.test.ts index 4286545..a781c67 100644 --- a/tests/e2e/stash-github-scenarios.e2e.test.ts +++ b/tests/e2e/stash-github-scenarios.e2e.test.ts @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import { mkdir, mkdtemp, + readdir, readFile, realpath, rename, @@ -18,8 +19,9 @@ import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { getStashDirPath, normalizeGlobalConfig } from "../../src/global-config.ts"; import { Stash } from "../../src/stash.ts"; -import type { GlobalConfig } from "../../src/types.ts"; +import type { GlobalConfig, StashRecord } from "../../src/types.ts"; import { hashBuffer } from "../../src/utils/hash.ts"; const execFileAsync = promisify(execFile); @@ -28,10 +30,53 @@ const token = process.env.GITHUB_TOKEN; const E2E_OPTIONS = { skip: !token, timeout: 300_000 }; type RepoInfo = { fullName: string }; -type Machine = { dir: string; stash: Stash }; +type Machine = { + dir: string; + id: string; + stash: Stash; + xdgDir: string; + stashPath: string; + metadataDir: string; +}; let cachedUsername: string | null = null; let lastRepoCreateAt = 0; +function getTestStashPath(xdgDir: string): string { + return join(xdgDir, "stash"); +} + +function makeGlobalConfig(tokenValue = token!): GlobalConfig { + return normalizeGlobalConfig({ + providers: { + github: { token: tokenValue }, + }, + }); +} + +async function listStashIds(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; + } + return (await readdir(stashPath)).filter( + (entry) => !entry.startsWith(".") && entry !== "config.json", + ); +} + +async function readStashRecord(stashPath: string, id: string): Promise { + return JSON.parse(await readFile(join(stashPath, id, "stash.json"), "utf8")) as StashRecord; +} + +async function readOnlyStashRecord( + stashPath: string, +): Promise<{ id: string; record: StashRecord }> { + const ids = await listStashIds(stashPath); + assert.equal(ids.length, 1); + return { + id: ids[0], + record: await readStashRecord(stashPath, ids[0]), + }; +} + async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -265,14 +310,54 @@ async function upsertRemoteFile( await githubJson("PUT", `/repos/${repo}/contents/${encodePath(path)}`, payload); } +async function createTestStash( + dir: string, + globalConfig = makeGlobalConfig(), + xdgDir?: string, +): Promise<{ + id: string; + stash: Stash; + xdgDir: string; + stashPath: string; + metadataDir: string; +}> { + const resolvedXdgDir = xdgDir ?? (await makeTempDir("xdg")); + const stashPath = getTestStashPath(resolvedXdgDir); + const canonicalPath = await realpath(dir); + const created = await Stash.create( + { + path: canonicalPath, + connections: [], + }, + globalConfig, + { stashPath }, + ); + return { + id: created.id, + stash: await Stash.load(created.id, globalConfig, { stashPath }), + xdgDir: resolvedXdgDir, + stashPath, + metadataDir: getStashDirPath(created.id, stashPath), + }; +} + async function createMachine( dir: string, repo: string, globalConfig: GlobalConfig, ): Promise { - const stash = await Stash.init(dir, globalConfig); + const canonicalPath = await realpath(dir); + const created = await createTestStash(canonicalPath, globalConfig); + const { id, stash, xdgDir, stashPath, metadataDir } = created; await stash.connect({ name: "origin", provider: "github", repo }); - return { dir, stash }; + return { + dir: canonicalPath, + id, + stash, + xdgDir, + stashPath, + metadataDir, + }; } async function setupTwoMachineBaseline(initialFiles: Record): Promise<{ @@ -284,14 +369,7 @@ async function setupTwoMachineBaseline(initialFiles: Record { @@ -307,14 +390,15 @@ test("scenario 1: connect auto-inits stash and keeps existing files", E2E_OPTION const xdg = await makeTempDir("xdg"); try { await writeFile(join(dir, "hello.md"), "hello", "utf8"); - await runCli( - dir, - ["connect", "github", "origin", "--repo", "user/repo", "--token", "test-token"], - { - XDG_CONFIG_HOME: xdg, - }, - ); - assert.equal(existsSync(join(dir, ".stash")), true); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"], { + XDG_CONFIG_HOME: xdg, + }); + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], + }); + assert.equal(existsSync(join(dir, ".stash")), false); assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); } finally { await rm(dir, { recursive: true, force: true }); @@ -329,31 +413,29 @@ test( const dir = await makeTempDir("connect-reregister"); const xdg = await makeTempDir("xdg"); try { - await runCli( - dir, - ["connect", "github", "origin", "--repo", "user/repo", "--token", "test-token"], - { - XDG_CONFIG_HOME: xdg, - }, - ); - const second = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "backup", "--repo", "user/repo"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: xdg }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"], { + XDG_CONFIG_HOME: xdg, + }); + const second = await execFileAsync("node", [CLI_PATH, "connect", ".", "github:user/other"], { + cwd: dir, + env: { ...process.env, XDG_CONFIG_HOME: xdg }, + }).catch((error) => error as { stdout: string; stderr: string; code: number }); assert.equal(second.code, 1); const combined = `${second.stdout ?? ""}${second.stderr ?? ""}`; - assert.equal(combined.includes('already has connection "origin"'), true); - assert.equal(combined.includes("stash disconnect origin"), true); + assert.equal(combined.includes("already connected to github:user/repo"), true); + assert.equal(combined.includes(`stash disconnect ${await realpath(dir)}`), true); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); - assert.deepEqual(globalConfig.background?.stashes, [await realpath(dir)]); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(Object.keys(localConfig.connections ?? {}), ["origin"]); + const globalConfig = JSON.parse( + await readFile(join(getTestStashPath(xdg), "config.json"), "utf8"), + ); + assert.deepEqual(globalConfig, { + providers: { + github: { token: "test-token" }, + }, + }); + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record.connections, [{ provider: "github", config: { id: "user/repo" } }]); } finally { await rm(dir, { recursive: true, force: true }); await rm(xdg, { recursive: true, force: true }); @@ -368,15 +450,12 @@ test("scenario 3: setup stores provider credentials globally", E2E_OPTIONS, asyn await runCli(dir, ["setup", "github", "--token", "test-token"], { XDG_CONFIG_HOME: xdg, }); - const configPath = join(xdg, "stash", "config.json"); + const configPath = join(getTestStashPath(xdg), "config.json"); const config = JSON.parse(await readFile(configPath, "utf8")); assert.deepEqual(config, { providers: { github: { token: "test-token" }, }, - background: { - stashes: [], - }, }); } finally { await rm(dir, { recursive: true, force: true }); @@ -391,16 +470,23 @@ test("scenario 4: connect stores stash connection config", E2E_OPTIONS, async () await runCli(dir, ["setup", "github", "--token", "test-token"], { XDG_CONFIG_HOME: xdg, }); - await runCli(dir, ["connect", "github", "origin", "--repo", "user/repo"], { + await runCli(dir, ["connect", ".", "github:user/repo"], { XDG_CONFIG_HOME: xdg, }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { origin: { provider: "github", repo: "user/repo" } }, + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], }); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); - assert.deepEqual(globalConfig.background?.stashes, [await realpath(dir)]); + const globalConfig = JSON.parse( + await readFile(join(getTestStashPath(xdg), "config.json"), "utf8"), + ); + assert.deepEqual(globalConfig, { + providers: { + github: { token: "test-token" }, + }, + }); } finally { await rm(dir, { recursive: true, force: true }); await rm(xdg, { recursive: true, force: true }); @@ -411,29 +497,23 @@ test("scenario 5: connect auto-writes setup config when missing", E2E_OPTIONS, a const dir = await makeTempDir("connect-autosetup"); const xdg = await makeTempDir("xdg"); try { - await runCli( - dir, - ["connect", "github", "origin", "--repo", "user/repo", "--token", "from-connect"], - { - XDG_CONFIG_HOME: xdg, - }, - ); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "from-connect"], { + XDG_CONFIG_HOME: xdg, + }); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); + const globalConfig = JSON.parse( + await readFile(join(getTestStashPath(xdg), "config.json"), "utf8"), + ); assert.deepEqual(globalConfig, { providers: { github: { token: "from-connect" }, }, - background: { - stashes: [await realpath(dir)], - }, }); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(localConfig, { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], }); } finally { await rm(dir, { recursive: true, force: true }); @@ -449,17 +529,19 @@ test("scenario 6: disconnect removes connection and sync becomes no-op", E2E_OPT await runCli(dir, ["setup", "github", "--token", "test-token"], { XDG_CONFIG_HOME: xdg, }); - await runCli(dir, ["connect", "github", "origin", "--repo", "user/repo"], { + await runCli(dir, ["connect", ".", "github:user/repo"], { XDG_CONFIG_HOME: xdg, }); - await runCli(dir, ["disconnect", "origin"], { XDG_CONFIG_HOME: xdg }); + await runCli(dir, ["disconnect", "."], { XDG_CONFIG_HOME: xdg }); assert.equal(existsSync(join(dir, ".stash")), false); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); - assert.deepEqual(globalConfig.background?.stashes, []); + const stashPath = getTestStashPath(xdg); + const { id, record } = await readOnlyStashRecord(stashPath); + assert.deepEqual(record.connections, []); - await assert.rejects(runCli(dir, ["sync"], { XDG_CONFIG_HOME: xdg })); + const stash = await Stash.load(id, makeGlobalConfig("test-token"), { stashPath }); + await syncWithRetry(stash); assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); } finally { await rm(dir, { recursive: true, force: true }); @@ -485,21 +567,15 @@ test("scenario 8: first sync pulls remote files to empty local", E2E_OPTIONS, as const machine = await makeTempDir("machine"); dirs.push(machine); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); assert.equal(await readFile(join(machine, "readme.md"), "utf8"), "welcome"); assert.equal(await readFile(join(machine, "data/config.json"), "utf8"), "{}"); - assert.equal(existsSync(join(machine, ".stash", "snapshot.json")), true); - assert.equal(existsSync(join(machine, ".stash", "snapshot", "readme.md")), true); + assert.equal(existsSync(join(metadataDir, "snapshot.json")), true); + assert.equal(existsSync(join(metadataDir, "snapshot", "readme.md")), true); } finally { if (repo) await deleteRepo(repo.fullName); await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }))); @@ -517,21 +593,15 @@ test("scenario 8b: fresh stash pulls nested dirs from non-stash repo", E2E_OPTIO const machine = await makeTempDir("fresh-pull-nested"); dirs.push(machine); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); assert.equal(await readFile(join(machine, "readme.md"), "utf8"), "hello"); assert.equal(await readFile(join(machine, "docs/guide.md"), "utf8"), "guide content"); assert.equal(await readFile(join(machine, "docs/deep/nested.md"), "utf8"), "deep content"); - assert.equal(existsSync(join(machine, ".stash", "snapshot.json")), true); + assert.equal(existsSync(join(metadataDir, "snapshot.json")), true); } finally { if (repo) await deleteRepo(repo.fullName); await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }))); @@ -544,15 +614,9 @@ test("scenario 8c: new machine joins existing stash with nested dirs", E2E_OPTIO try { repo = await createRepo(); const machineADir = await makeTempDir("machine-a"); - dirs.push(machineADir); - const machineA = await createMachine(machineADir, repo.fullName, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const globalConfig = makeGlobalConfig(); + const machineA = await createMachine(machineADir, repo.fullName, globalConfig); + dirs.push(machineA.dir, machineA.xdgDir); await writeLocalFiles(machineADir, { "readme.md": "hello", "docs/guide.md": "guide", @@ -561,15 +625,8 @@ test("scenario 8c: new machine joins existing stash with nested dirs", E2E_OPTIO await syncWithRetry(machineA.stash); const machineBDir = await makeTempDir("machine-b"); - dirs.push(machineBDir); - const machineB = await createMachine(machineBDir, repo.fullName, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const machineB = await createMachine(machineBDir, repo.fullName, globalConfig); + dirs.push(machineB.dir, machineB.xdgDir); await syncWithRetry(machineB.stash); assert.equal(await readFile(join(machineBDir, "readme.md"), "utf8"), "hello"); @@ -597,14 +654,8 @@ test( const machine = await makeTempDir("machine"); dirs.push(machine); await writeFile(join(machine, "local.md"), "local content", "utf8"); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); @@ -623,7 +674,7 @@ test( assert.equal(await readFile(join(machine, "local.md"), "utf8"), "local content"); assert.equal(await remoteFileExists(repo.fullName, "shared.md"), true); assert.equal(await remoteFileExists(repo.fullName, "local.md"), true); - const snapshot = JSON.parse(await readFile(join(machine, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(typeof snapshot["shared.md"]?.hash, "string"); assert.equal(typeof snapshot["local.md"]?.hash, "string"); } finally { @@ -892,20 +943,19 @@ test("scenario 21: concurrent binary edits resolve to last writer", E2E_OPTIONS, test("scenario 22: sync with no connection is a no-op", E2E_OPTIONS, async () => { const dir = await makeTempDir("no-connection"); + let xdgDir: string | null = null; try { await writeFile(join(dir, "hello.md"), "hello", "utf8"); - const stash = await Stash.init(dir, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const created = await createTestStash(dir); + xdgDir = created.xdgDir; + const { stash } = created; await syncWithRetry(stash); assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); } finally { await rm(dir, { recursive: true, force: true }); + if (xdgDir) { + await rm(xdgDir, { recursive: true, force: true }); + } } }); @@ -989,10 +1039,10 @@ test("scenario 25: multiple sync cycles converge to identical state", E2E_OPTION throw new Error("Failed to validate converged file set"); } const snapA = JSON.parse( - await readFile(join(setup.machineA.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(setup.machineA.metadataDir, "snapshot.json"), "utf8"), ); const snapB = JSON.parse( - await readFile(join(setup.machineB.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(setup.machineB.metadataDir, "snapshot.json"), "utf8"), ); assert.deepEqual(snapA, snapB); } finally { @@ -1032,14 +1082,8 @@ test("scenario 27: status before first sync has null lastSync", E2E_OPTIONS, asy const dir = await makeTempDir("status-first"); dirs.push(dir); await writeFile(join(dir, "hello.md"), "hello", "utf8"); - const stash = await Stash.init(dir, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir } = await createTestStash(dir); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); const status = stash.status(); assert.deepEqual(status.added, ["hello.md"]); @@ -1108,12 +1152,12 @@ test("scenario 30: symlinks are ignored", E2E_OPTIONS, async () => { } }); -test("scenario 31: .stash local metadata is ignored remotely", E2E_OPTIONS, async () => { +test("scenario 31: stash metadata is ignored remotely", E2E_OPTIONS, async () => { let setup: Awaited> | null = null; try { setup = await setupTwoMachineBaseline({ "hello.md": "hello" }); await syncWithRetry(setup.machineA.stash); - assert.equal(await remoteFileExists(setup.repo.fullName, ".stash/config.json"), false); + assert.equal(existsSync(join(setup.machineA.dir, ".stash")), false); assert.equal(await remoteFileExists(setup.repo.fullName, ".stash/snapshot/hello.md"), false); assert.equal(await remoteFileExists(setup.repo.fullName, ".stash/snapshot.json"), true); } finally { @@ -1137,14 +1181,8 @@ test( const machine = await makeTempDir("identical-first-sync"); dirs.push(machine); await writeLocalFiles(machine, { "hello.md": "hello" }); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); @@ -1152,9 +1190,7 @@ test( assert.equal(await remoteFileExistsRetry(repo.fullName, ".stash/snapshot.json"), true); assert.equal(await readRemoteText(repo.fullName, "hello.md"), "hello"); - const localSnapshot = JSON.parse( - await readFile(join(machine, ".stash", "snapshot.json"), "utf8"), - ); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(typeof localSnapshot["hello.md"]?.hash, "string"); assert.equal(localSnapshot["hello.md"].hash, hashBuffer(Buffer.from("hello", "utf8"))); @@ -1215,10 +1251,10 @@ test("scenario 37: directory case rename converges across two machines", E2E_OPT // Verify both snapshots agree const snapshotA = JSON.parse( - await readFile(join(machineA.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(machineA.metadataDir, "snapshot.json"), "utf8"), ); const snapshotB = JSON.parse( - await readFile(join(machineB.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(machineB.metadataDir, "snapshot.json"), "utf8"), ); assert.deepEqual(snapshotA, snapshotB); assert.ok(snapshotA["notes/draft.md"]); @@ -1271,18 +1307,18 @@ test("e2e: daemon syncs a registered stash and writes status.json", E2E_OPTIONS, const xdgDir = await makeTempDir("daemon-e2e-xdg"); dirs.push(dir, xdgDir); - const globalConfig: GlobalConfig = { - providers: { github: { token: token! } }, - background: { stashes: [dir] }, - }; - - const stash = await Stash.init(dir, globalConfig); + const globalConfig = makeGlobalConfig(); + const { stash, metadataDir } = await createTestStash(dir, globalConfig, xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await writeLocalFiles(dir, { "daemon-test.txt": "hello from daemon" }); - const configDir = join(xdgDir, "stash"); + const configDir = getTestStashPath(xdgDir); await mkdir(configDir, { recursive: true }); - await writeFile(join(configDir, "config.json"), JSON.stringify(globalConfig, null, 2), "utf8"); + await writeFile( + join(configDir, "config.json"), + JSON.stringify({ providers: globalConfig.providers }, null, 2), + "utf8", + ); const daemon = spawn("node", [CLI_PATH, "daemon"], { env: { ...process.env, XDG_CONFIG_HOME: xdgDir }, @@ -1299,7 +1335,7 @@ test("e2e: daemon syncs a registered stash and writes status.json", E2E_OPTIONS, daemonStderr += chunk; }); - const statusPath = join(dir, ".stash", "status.json"); + const statusPath = join(metadataDir, "status.json"); let synced = false; let lastStatus: Record | null = null; for (let i = 0; i < 30; i += 1) { @@ -1330,7 +1366,7 @@ test("e2e: daemon syncs a registered stash and writes status.json", E2E_OPTIONS, const status = JSON.parse(await readFile(statusPath, "utf8")); assert.ok(status.lastSync, "status should have a lastSync timestamp"); - const logPath = join(dir, ".stash", "sync.log"); + const logPath = join(metadataDir, "sync.log"); assert.equal(existsSync(logPath), true, "sync.log should exist"); const log = await readFile(logPath, "utf8"); assert.ok(log.length > 0, "sync.log should not be empty"); diff --git a/tests/e2e/stash-github.e2e.test.ts b/tests/e2e/stash-github.e2e.test.ts index 665d813..3bb3d4e 100644 --- a/tests/e2e/stash-github.e2e.test.ts +++ b/tests/e2e/stash-github.e2e.test.ts @@ -1,10 +1,23 @@ import test from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Stash } from "../../src/stash.ts"; +import type { GlobalConfig } from "../../src/types.ts"; + +async function initStash(dir: string, globalConfig: GlobalConfig): Promise { + const xdgDir = await mkdtemp(join(tmpdir(), `xdg-${randomUUID().slice(0, 8)}-`)); + const stashPath = join(xdgDir, "stash"); + const canonicalPath = await realpath(dir); + const created = await Stash.create( + { path: canonicalPath, connections: [] }, + globalConfig, + { stashPath }, + ); + return Stash.load(created.id, globalConfig, { stashPath }); +} const token = process.env.GITHUB_TOKEN; @@ -176,7 +189,7 @@ test( stashes: [], }, }; - const stashA = await Stash.init(machineA, globalConfig); + const stashA = await initStash(machineA, globalConfig); await stashA.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashA); @@ -184,7 +197,7 @@ test( assert.equal(await getRawFile(repo.fullName, "notes.md"), "note"); assert.ok(await getRawFile(repo.fullName, ".stash/snapshot.json")); - const stashB = await Stash.init(machineB, globalConfig); + const stashB = await initStash(machineB, globalConfig); await stashB.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashB); @@ -221,11 +234,11 @@ test( }; await writeFile(join(machineA, "hello.md"), "line1\nline2\nline3", "utf8"); - const stashA = await Stash.init(machineA, globalConfig); + const stashA = await initStash(machineA, globalConfig); await stashA.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashA); - const stashB = await Stash.init(machineB, globalConfig); + const stashB = await initStash(machineB, globalConfig); await stashB.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashB); diff --git a/tests/helpers/assert-migration.ts b/tests/helpers/assert-migration.ts deleted file mode 100644 index 7030e1f..0000000 --- a/tests/helpers/assert-migration.ts +++ /dev/null @@ -1,71 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { SnapshotEntry } from "../../src/types.ts"; - -type LegacyConnectionConfig = Record & { provider?: string }; - -type LegacyLayoutOptions = { - connections?: Record; - snapshot?: Record; - snapshotLocal?: Record; - "allow-git"?: boolean; -}; - -export async function writeLegacyLayout( - dir: string, - options: LegacyLayoutOptions = {}, -): Promise { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify( - { - connections: options.connections ?? {}, - ...(options["allow-git"] === undefined ? {} : { "allow-git": options["allow-git"] }), - }, - null, - 2, - ), - "utf8", - ); - - if (options.snapshot) { - await writeFile( - join(dir, ".stash", "snapshot.json"), - JSON.stringify(options.snapshot, null, 2), - "utf8", - ); - } - - if (options.snapshotLocal) { - for (const [path, content] of Object.entries(options.snapshotLocal)) { - const target: string = join(dir, ".stash", "snapshot.local", path); - await mkdir(join(target, ".."), { recursive: true }); - await writeFile(target, content, "utf8"); - } - } -} - -export async function assertMigration( - dir: string, - expected: LegacyLayoutOptions = {}, -): Promise { - assert.equal(existsSync(join(dir, ".stash", "config.local.json")), false); - assert.equal(existsSync(join(dir, ".stash", "snapshot.local")), false); - assert.equal(existsSync(join(dir, ".stash", "config.json")), true); - - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")) as Record< - string, - unknown - >; - assert.deepEqual(config.connections, expected.connections ?? {}); - if (expected["allow-git"] !== undefined) { - assert.equal(config["allow-git"], expected["allow-git"]); - } - - for (const [path, content] of Object.entries(expected.snapshotLocal ?? {})) { - assert.equal(await readFile(join(dir, ".stash", "snapshot", path), "utf8"), content); - } -} diff --git a/tests/helpers/make-stash.ts b/tests/helpers/make-stash.ts index 3825bfe..e4cb7ab 100644 --- a/tests/helpers/make-stash.ts +++ b/tests/helpers/make-stash.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { normalizeGlobalConfig } from "../../src/global-config.ts"; +import { getStashDirPath, normalizeGlobalConfig } from "../../src/global-config.ts"; import { Stash } from "../../src/stash.ts"; import type { GlobalConfig, ProviderClass, SnapshotEntry } from "../../src/types.ts"; @@ -29,20 +29,30 @@ export async function makeStash( globalConfig?: GlobalConfig; providers?: Record; }, -): Promise<{ stash: Stash; dir: string }> { +): Promise<{ stash: Stash; dir: string; stashPath: string; metadataDir: string }> { const dir = await makeTempDir(); + const stashPath = join(dir, ".xdg", "stash"); const globalConfig = normalizeGlobalConfig(opts?.globalConfig ?? {}); if (files) { await writeFiles(dir, files); } - await Stash.init(dir, globalConfig, { - providers: opts?.providers, - }); + const stash = await Stash.create( + { + path: dir, + connections: [], + }, + globalConfig, + { + providers: opts?.providers, + stashPath, + }, + ); + const metadataDir = getStashDirPath(stash.id, stashPath); if (opts?.snapshot) { await writeFile( - join(dir, ".stash", "snapshot.json"), + join(metadataDir, "snapshot.json"), JSON.stringify(opts.snapshot, null, 2), "utf8", ); @@ -50,14 +60,15 @@ export async function makeStash( if (opts?.snapshotLocal) { for (const [path, content] of Object.entries(opts.snapshotLocal)) { - const target = join(dir, ".stash", "snapshot", path); + const target = join(metadataDir, "snapshot", path); await mkdir(join(target, ".."), { recursive: true }); await writeFile(target, content, "utf8"); } } - const loaded = await Stash.load(dir, globalConfig, { + const loaded = await Stash.load(stash.id, globalConfig, { providers: opts?.providers, + stashPath, }); - return { stash: loaded, dir }; + return { stash: loaded, dir, stashPath, metadataDir }; } diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index 1b056d6..555f25f 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -1,14 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; -import { UnsupportedPlatformError } from "@rupertsworld/daemon"; +import { join, resolve } from "node:path"; import { main } from "../../src/cli-main.ts"; -import { Stash } from "../../src/stash.ts"; -import type { GlobalConfig } from "../../src/types.ts"; -import { assertMigration, writeLegacyLayout } from "../helpers/assert-migration.ts"; +import type { GlobalConfig, StashRecord } from "../../src/types.ts"; type ServiceCalls = { install: number; @@ -17,23 +14,55 @@ type ServiceCalls = { }; type RunResult = { + exitCode: number; stdout: string; stderr: string; config: GlobalConfig; + configWrites: number; calls: ServiceCalls; }; +const ANSI_PATTERN = /\x1B\[[0-9;]*m/g; + async function makeTempDir(prefix: string): Promise { return mkdtemp(join(tmpdir(), `${prefix}-`)); } +function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ""); +} + +function getTestStashPath(dir: string): string { + return join(dir, ".xdg", "stash"); +} + +async function listStashIds(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; + } + return (await readdir(stashPath)).filter((entry) => !entry.startsWith(".") && entry !== "config.json"); +} + +async function readOnlyStashRecord(stashPath: string): Promise<{ id: string; record: StashRecord }> { + const ids = await listStashIds(stashPath); + assert.equal(ids.length, 1); + return { + id: ids[0], + record: JSON.parse(await readFile(join(stashPath, ids[0], "stash.json"), "utf8")) as StashRecord, + }; +} + +async function writeStashRecord(stashPath: string, id: string, record: StashRecord): Promise { + await mkdir(join(stashPath, id), { recursive: true }); + await writeFile(join(stashPath, id, "stash.json"), JSON.stringify(record, null, 2), "utf8"); +} + async function runMain( cwd: string, args: string[], options?: { config?: GlobalConfig; serviceStatus?: { installed: boolean; running: boolean } | Error; - tty?: boolean; }, ): Promise { let config: GlobalConfig = structuredClone( @@ -46,16 +75,19 @@ async function runMain( ); let stdout = ""; let stderr = ""; + let configWrites = 0; const calls: ServiceCalls = { install: 0, uninstall: 0, status: 0, }; - await main(["node", "stash", ...args], { + const exitCode = await main(["node", "stash", ...args], { cwd: () => cwd, + stashPath: getTestStashPath(cwd), readGlobalConfig: async () => structuredClone(config), writeGlobalConfig: async (nextConfig) => { + configWrites += 1; config = structuredClone(nextConfig); }, service: { @@ -74,14 +106,14 @@ async function runMain( }, }, stdout: { - isTTY: options?.tty === true, + isTTY: false, write(chunk: string) { stdout += chunk; return true; }, } as NodeJS.WriteStream, stderr: { - isTTY: options?.tty === true, + isTTY: false, write(chunk: string) { stderr += chunk; return true; @@ -89,489 +121,196 @@ async function runMain( } as NodeJS.WriteStream, }); - return { stdout, stderr, config, calls }; + return { exitCode, stdout, stderr, config, configWrites, calls }; } -test("connect registers the current stash path in the background registry", async () => { - const dir = await makeTempDir("stash-cli-connect-register"); - - try { - const result = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - assert.equal(result.stdout.includes("Connected origin."), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connect defaults the connection name to the provider when omitted", async () => { - const dir = await makeTempDir("stash-cli-connect-default-name"); - - try { - const result = await runMain(dir, [ - "connect", - "github", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - assert.equal(result.stdout.includes("Connected github."), true); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(localConfig, { - connections: { - github: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connect reports that the stash is now syncing when background sync is already running", async () => { - const dir = await makeTempDir("stash-cli-connect-running"); +test("connect creates a new stash record and stores provider globals separately", async () => { + const dir = await makeTempDir("stash-cli-connect"); try { const result = await runMain( dir, - ["connect", "github", "origin", "--token", "test-token", "--repo", "user/repo"], + ["connect", ".", "github:user/repo", "--token", "test-token"], { serviceStatus: { installed: true, running: true }, }, ); - assert.equal(result.stdout.includes("Connected origin."), true); - assert.equal(result.stdout.includes("Background sync is on"), true); - assert.equal(result.stdout.includes("This stash is now syncing automatically"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connect warns when git repository syncing is still blocked", async () => { - const dir = await makeTempDir("stash-cli-connect-git-warning"); - - try { - await mkdir(join(dir, ".git"), { recursive: true }); - - const result = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - assert.equal(result.stdout.includes("Connected origin."), true); - assert.equal(result.stdout.includes("Warning:"), true); - assert.equal(result.stdout.includes("remove .git"), true); - assert.equal(result.stdout.includes("stash config set allow-git true"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connecting a second named connection is rejected", async () => { - const dir = await makeTempDir("stash-cli-connect-multi"); - - try { - const first = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - const error = await runMain( - dir, - ["connect", "github", "backup", "--token", "test-token", "--repo", "user/other"], - { - config: first.config, - }, - ).catch((e: Error) => e); - assert.ok(error instanceof Error); - assert.equal(error.message.includes('already has connection "origin"'), true); - assert.equal(error.message.includes("stash disconnect origin"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("disconnect keeps the stash registered while other providers remain", async () => { - const dir = await makeTempDir("stash-cli-disconnect-keep"); - - try { - await Stash.init(dir, { - providers: {}, - background: { stashes: [resolve(dir)] }, - }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - backup: { provider: "fake", repo: "other/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const result = await runMain(dir, ["disconnect", "origin"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - }); + const stdout = stripAnsi(result.stdout); + assert.equal(result.exitCode, 0); + assert.equal(stdout.includes("Connected"), true); + assert.equal(stdout.includes("github:user/repo"), true); + assert.equal(stdout.includes(await realpath(dir)), true); + assert.equal(stdout.includes("Background sync is on. Run `stash status` to check."), true); + assert.equal(result.config.providers.github?.token, "test-token"); + assert.equal(existsSync(join(dir, ".stash")), false); - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(localConfig, { - connections: { - backup: { provider: "fake", repo: "other/repo" }, - }, + const { record } = await readOnlyStashRecord(getTestStashPath(dir)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect --all disconnects the stash completely", async () => { - const dir = await makeTempDir("stash-cli-disconnect-all"); +test("connect is idempotent for the same uri", async () => { + const dir = await makeTempDir("stash-cli-connect-idempotent"); try { - const connected = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - const disconnected = await runMain(dir, ["disconnect", "--all"], { - config: connected.config, + const first = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const second = await runMain(dir, ["connect", ".", "github:user/repo"], { + config: first.config, }); - assert.deepEqual(disconnected.config.background?.stashes, []); - assert.equal(existsSync(join(dir, ".stash")), false); - assert.equal(disconnected.stdout.includes("Disconnected stash."), true); + const stdout = stripAnsi(second.stdout); + assert.equal(second.exitCode, 0); + assert.equal(stdout.includes("Already connected"), true); + assert.equal(stdout.includes("github:user/repo"), true); + assert.deepEqual(await listStashIds(getTestStashPath(dir)), [( + await readOnlyStashRecord(getTestStashPath(dir)) + ).id]); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect with no arguments errors with actionable guidance", async () => { - const dir = await makeTempDir("stash-cli-disconnect-missing"); +test("connect skips rewriting global config when provider globals are unchanged", async () => { + const dir = await makeTempDir("stash-cli-connect-global-unchanged"); try { - const result = await main(["node", "stash", "disconnect"], { - cwd: () => dir, - readGlobalConfig: async () => ({ providers: {}, background: { stashes: [] } }), - writeGlobalConfig: async () => {}, - service: { - install: async () => {}, - uninstall: async () => {}, - status: async () => ({ installed: false, running: false }), - }, - stdout: { - isTTY: false, - write() { - return true; + const result = await runMain(dir, ["connect", ".", "github:user/repo"], { + config: { + providers: { + github: { token: "test-token" }, }, - } as NodeJS.WriteStream, - stderr: { - isTTY: false, - write() { - return true; + background: { + stashes: [], }, - } as NodeJS.WriteStream, - }).catch((error) => error as Error); - - assert.equal(result instanceof Error, true); - assert.equal( - (result as Error).message, - "argument required — run `stash disconnect `, `stash disconnect --all`, or `stash disconnect --path `", - ); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("disconnect removes .stash and unregisters when it was the last connection", async () => { - const dir = await makeTempDir("stash-cli-disconnect-remove"); - - try { - const connected = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - const disconnected = await runMain(dir, ["disconnect", "origin"], { - config: connected.config, + }, }); - assert.deepEqual(disconnected.config.background?.stashes, []); - assert.equal(existsSync(join(dir, ".stash")), false); - assert.equal(disconnected.stdout.includes("Disconnected origin."), true); + assert.equal(result.exitCode, 0); + assert.equal(result.configWrites, 0); + assert.deepEqual(result.config.providers.github, { token: "test-token" }); + const { record } = await readOnlyStashRecord(getTestStashPath(dir)); + assert.deepEqual(record.connections, [{ provider: "github", config: { id: "user/repo" } }]); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect --path deregisters the path and removes .stash when the directory exists", async () => { - const dir = await makeTempDir("stash-cli-disconnect-path"); +test("connect blocks git repositories unless --dangerously-allow-git is passed", async () => { + const dir = await makeTempDir("stash-cli-connect-git"); try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); + await mkdir(join(dir, ".git"), { recursive: true }); - const result = await runMain(dir, ["disconnect", "--path", dir], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - }); + const result = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); - assert.deepEqual(result.config.background?.stashes, []); - assert.equal(existsSync(join(dir, ".stash")), false); - assert.equal(result.stdout.includes("Disconnected stash."), true); + const stderr = stripAnsi(result.stderr); + assert.equal(result.exitCode, 1); + assert.equal(stderr.includes("Error:"), true); + assert.equal(stderr.includes("is a git repository"), true); + assert.equal(stderr.includes("--dangerously-allow-git"), true); + assert.equal(stripAnsi(result.stdout).includes("Background sync is on"), false); + assert.deepEqual(await listStashIds(getTestStashPath(dir)), []); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect --path no-ops cleanly when the path is not registered", async () => { - const dir = await makeTempDir("stash-cli-disconnect-path-missing"); - const missing = join(dir, "missing-stash"); +test("disconnect tombstones the last connection without deleting the stash dir", async () => { + const dir = await makeTempDir("stash-cli-disconnect"); try { - const result = await runMain(dir, ["disconnect", "--path", missing], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, + const connected = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const { id } = await readOnlyStashRecord(getTestStashPath(dir)); + + const result = await runMain(dir, ["disconnect", "."], { + config: connected.config, }); - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - assert.equal(result.stdout.includes("No stash registered at that path."), true); + const stdout = stripAnsi(result.stdout); + assert.equal(result.exitCode, 0); + assert.equal(stdout.includes("Disconnected"), true); + assert.equal(stdout.includes("github:user/repo"), true); + assert.equal(stdout.includes(await realpath(dir)), true); + assert.equal(existsSync(join(getTestStashPath(dir), id)), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as StashRecord; + assert.deepEqual(record.connections, []); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect errors when the named connection does not exist", async () => { - const dir = await makeTempDir("stash-cli-disconnect-missing-name"); +test("relink rewrites stash.json.path and enforces the git gate on the new path", async () => { + const dir = await makeTempDir("stash-cli-relink-old"); + const moved = await makeTempDir("stash-cli-relink-new"); try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const result = await main(["node", "stash", "disconnect", "backup"], { - cwd: () => dir, - readGlobalConfig: async () => ({ providers: {}, background: { stashes: [resolve(dir)] } }), - writeGlobalConfig: async () => {}, - service: { - install: async () => {}, - uninstall: async () => {}, - status: async () => ({ installed: false, running: false }), - }, - stdout: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - stderr: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - }).catch((error) => error as Error); - - assert.equal(result instanceof Error, true); - assert.equal((result as Error).message, "Connection not found: backup"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); + const connected = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const { id } = await readOnlyStashRecord(getTestStashPath(dir)); -test("disconnect flags are mutually exclusive", async () => { - const dir = await makeTempDir("stash-cli-disconnect-exclusive"); + await mkdir(join(moved, ".git"), { recursive: true }); + const error = await runMain(dir, ["relink", dir, moved], { config: connected.config }); + const errorStderr = stripAnsi(error.stderr); + assert.equal(error.exitCode, 1); + assert.equal(errorStderr.includes("Error:"), true); + assert.equal(errorStderr.includes("is a git repository"), true); + assert.equal(errorStderr.includes("--dangerously-allow-git"), true); - try { - const result = await main(["node", "stash", "disconnect", "origin", "--all"], { - cwd: () => dir, - readGlobalConfig: async () => ({ providers: {}, background: { stashes: [] } }), - writeGlobalConfig: async () => {}, - service: { - install: async () => {}, - uninstall: async () => {}, - status: async () => ({ installed: false, running: false }), - }, - stdout: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - stderr: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - }).catch((error) => error as Error); - - assert.equal(result instanceof Error, true); - assert.equal( - (result as Error).message, - "disconnect modes are mutually exclusive — use only one of ``, `--all`, or `--path `", + const result = await runMain( + dir, + ["relink", dir, moved, "--dangerously-allow-git"], + { config: connected.config }, ); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); -test("start delegates to install and reports the service is on", async () => { - const dir = await makeTempDir("stash-cli-start"); - - try { - const result = await runMain(dir, ["start"], { - config: { - providers: {}, - background: { stashes: ["/a", "/b", "/c"] }, - }, - }); - - assert.equal(result.calls.install, 1); - assert.equal(result.stdout.includes("Background sync is on"), true); - assert.equal(result.stdout.includes("Watching 3 stashes"), true); - assert.equal(result.stdout.includes("starts on startup"), true); + const stdout = stripAnsi(result.stdout); + assert.equal(result.exitCode, 0); + assert.equal(stdout.includes("Relinked"), true); + assert.equal(stdout.includes(await realpath(dir)), true); + assert.equal(stdout.includes(await realpath(moved)), true); + assert.equal(stdout.includes("→"), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as StashRecord; + assert.equal(record.path, await realpath(moved)); } finally { await rm(dir, { recursive: true, force: true }); + await rm(moved, { recursive: true, force: true }); } }); -test("stop delegates to uninstall and reports how to resume syncing", async () => { - const dir = await makeTempDir("stash-cli-stop"); +test("status prints daemon state, connection URIs, and missing-path guidance", async () => { + const dir = await makeTempDir("stash-cli-status"); + const missing = join(dir, "missing-folder"); + const stashPath = getTestStashPath(dir); try { - const result = await runMain(dir, ["stop"], { - config: { - providers: {}, - background: { stashes: ["/a", "/b", "/c"] }, - }, - serviceStatus: { installed: true, running: true }, + await writeStashRecord(stashPath, "live-stash", { + path: resolve(dir), + connections: [{ provider: "github", config: { id: "user/live" } }], + }); + await writeStashRecord(stashPath, "missing-stash", { + path: resolve(missing), + connections: [{ provider: "github", config: { id: "user/missing" } }], }); - - assert.equal(result.calls.uninstall, 1); - assert.equal(result.stdout.includes("Background sync is off"), true); - assert.equal(result.stdout.includes("Run `stash start` to resume syncing 3 stashes"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status prints unsupported-platform state and per-stash summaries", async () => { - const dir = await makeTempDir("stash-cli-status-all"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); await writeFile( - join(dir, ".stash", "status.json"), + join(stashPath, "live-stash", "status.json"), JSON.stringify( { - kind: "synced", + kind: "idle", lastSync: "2026-03-11T14:30:00.000Z", - summary: "1↑ 2↓", + summary: "1 modified", error: null, - }, - null, - 2, - ), - "utf8", - ); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { - stashes: [dir], - }, - }, - serviceStatus: new UnsupportedPlatformError(), - }); - - assert.equal(result.stdout.includes("not supported on this platform"), true); - assert.equal(result.stdout.includes(basename(dir)), true); - assert.equal(result.stdout.includes(dir), true); - assert.equal(result.stdout.includes("origin (github)"), true); - assert.equal(result.stdout.includes("user/repo"), false); - assert.equal(result.stdout.includes("Up to date"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status shows the git safety error message", async () => { - const dir = await makeTempDir("stash-cli-status-all-git-error"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, + pendingChanges: { + modified: 1, + added: 0, + deleted: 0, }, }, null, @@ -580,13 +319,13 @@ test("status shows the git safety error message", async () => { "utf8", ); await writeFile( - join(dir, ".stash", "status.json"), + join(stashPath, "missing-stash", "status.json"), JSON.stringify( { - kind: "error", + kind: "missing", lastSync: null, summary: null, - error: "git repository detected — run `stash config set allow-git true` to allow syncing", + error: null, }, null, 2, @@ -595,70 +334,62 @@ test("status shows the git safety error message", async () => { ); const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { - stashes: [dir], - }, - }, - serviceStatus: new UnsupportedPlatformError(), + serviceStatus: { installed: true, running: true }, }); - assert.equal(result.stdout.includes("git repository detected"), true); - assert.equal(result.stdout.includes("stash config set allow-git true"), true); + const stdout = stripAnsi(result.stdout); + assert.equal(result.exitCode, 0); + assert.equal(stdout.includes("Daemon: running"), true); + assert.equal(stdout.includes(resolve(dir)), true); + assert.equal(stdout.includes("github:user/live"), true); + assert.equal(stdout.includes("Connection"), true); + assert.equal(stdout.includes("State"), true); + assert.equal(stdout.includes("Pending"), true); + assert.equal(stdout.includes("1 modified"), true); + assert.equal(stdout.includes(`${resolve(missing)} (path missing)`), true); + assert.equal(stdout.includes(`Run: stash relink ${resolve(missing)} `), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("status shows the current stash when background sync is running", async () => { - const dir = await makeTempDir("stash-cli-status-local"); +test("status shows daemon state and empty hint when no stashes are registered", async () => { + const dir = await makeTempDir("stash-cli-status-empty"); try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: true, running: true }, + serviceStatus: { installed: false, running: false }, }); - assert.equal(result.stdout.includes("origin (github)"), true); - assert.equal(result.stdout.includes("user/repo"), false); - assert.equal(result.stdout.includes("Background sync is on"), true); + const stdout = stripAnsi(result.stdout); + assert.equal(result.exitCode, 0); + assert.equal(stdout.includes("Daemon: stopped"), true); + assert.equal( + stdout.includes("No stashes registered. Run `stash connect ` to add one"), + true, + ); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("status shows waiting for first sync when no snapshot exists", async () => { - const dir = await makeTempDir("stash-cli-status-waiting"); +test("status renders the error state row and last error details for errored stashes", async () => { + const dir = await makeTempDir("stash-cli-status-error"); + const stashPath = getTestStashPath(dir); try { - await Stash.init(dir, { providers: {}, background: { stashes: [] } }); + await writeStashRecord(stashPath, "errored-stash", { + path: resolve(dir), + connections: [{ provider: "github", config: { id: "user/error" } }], + }); await writeFile( - join(dir, ".stash", "config.json"), + join(stashPath, "errored-stash", "status.json"), JSON.stringify( { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, + kind: "error", + lastSync: "2026-03-11T14:30:00.000Z", + summary: null, + error: "rate limit resets in 43s", }, null, 2, @@ -667,184 +398,50 @@ test("status shows waiting for first sync when no snapshot exists", async () => ); const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - tty: true, - }); - - assert.equal(result.stdout.includes("origin (github)"), true); - assert.equal(result.stdout.includes("Waiting for first sync"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status outside a stash shows no stashes connected", async () => { - const dir = await makeTempDir("stash-cli-status-outside"); - - try { - const result = await runMain(dir, ["status"], { - config: { providers: {}, background: { stashes: [] } }, - serviceStatus: { installed: false, running: false }, - }); - - assert.equal(result.stdout.includes("No stashes connected yet"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("migration stops the running daemon before migrating and restarts it after", async () => { - const dir = await makeTempDir("stash-cli-migrate-bounce"); - - try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: true, running: true }, - }); - - assert.equal(result.calls.uninstall, 1, "daemon should be stopped before migration"); - assert.equal(result.calls.install, 1, "daemon should be restarted after migration"); - await assertMigration(dir, { - connections: { github: { provider: "github", repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("migration does not bounce the daemon when it is not running", async () => { - const dir = await makeTempDir("stash-cli-migrate-no-bounce"); - - try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - }); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: false, running: false }, - }); - - assert.equal(result.calls.uninstall, 0, "daemon should not be stopped when not running"); - assert.equal(result.calls.install, 0, "daemon should not be installed when it was not running"); - await assertMigration(dir, { - connections: { github: { provider: "github", repo: "user/repo" } }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("migration bounces daemon for status when a background stash needs migration", async () => { - const dir = await makeTempDir("stash-cli-migrate-status-all"); - - try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [dir] }, - }, serviceStatus: { installed: true, running: true }, }); - assert.equal(result.calls.uninstall, 1, "daemon should be stopped before migration"); - assert.equal(result.calls.install, 1, "daemon should be restarted after migration"); - await assertMigration(dir, { - connections: { github: { provider: "github", repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); + const stdout = stripAnsi(result.stdout); + assert.equal(result.exitCode, 0); + assert.match(stdout, /State[\s\S]*error[\s\S]*Message/); + assert.equal(stdout.includes("Message"), true); + assert.equal(stdout.includes("rate limit resets in 43s"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("migration does not restart daemon after stash stop", async () => { - const dir = await makeTempDir("stash-cli-migrate-stop"); +test("error output is plain stderr with an Error prefix", async () => { + const dir = await makeTempDir("stash-cli-error-shape"); try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - }); + const result = await runMain(dir, ["disconnect", "."]); - const result = await runMain(dir, ["stop"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: true, running: true }, - }); - - assert.equal(result.calls.uninstall >= 1, true, "daemon should be stopped"); - assert.equal(result.calls.install, 0, "daemon should not be restarted after stop command"); + const stderr = stripAnsi(result.stderr); + assert.equal(result.exitCode, 1); + assert.equal(stderr.includes("Error: No stash found at"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("status prints background sync line above provider connections with a blank line gap", async () => { - const dir = await makeTempDir("stash-cli-status-layout"); +test("start and stop delegate to the service and use the new daemon messaging", async () => { + const dir = await makeTempDir("stash-cli-service"); try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); + const started = await runMain(dir, ["start"]); + const startedStdout = stripAnsi(started.stdout); + assert.equal(started.exitCode, 0); + assert.equal(started.calls.install, 1); + assert.equal(startedStdout.includes("Daemon installed and running"), true); - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, + const stopped = await runMain(dir, ["stop"], { serviceStatus: { installed: true, running: true }, }); - - const lines = result.stdout.split("\n"); - const bgLine = lines.findIndex((line) => line.includes("Background sync is on")); - const providerLine = lines.findIndex((line) => line.includes("origin (github)")); - assert.notEqual(bgLine, -1, "should contain background sync line"); - assert.notEqual(providerLine, -1, "should contain provider line"); - assert.equal( - bgLine < providerLine, - true, - "background sync line should appear before provider line", - ); - assert.equal( - lines[bgLine + 1]?.trim(), - "", - "blank line should follow the background sync line", - ); + const stoppedStdout = stripAnsi(stopped.stdout); + assert.equal(stopped.exitCode, 0); + assert.equal(stopped.calls.uninstall, 1); + assert.equal(stoppedStdout.includes("Daemon stopped"), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index 5234162..f71c8bb 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -3,69 +3,53 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { existsSync } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { assertMigration, writeLegacyLayout } from "../helpers/assert-migration.ts"; import { makeTempDir } from "../helpers/make-stash.ts"; const execFileAsync = promisify(execFile); const CLI_PATH = fileURLToPath(new URL("../../src/cli.ts", import.meta.url)); +const ANSI_PATTERN = /\x1B\[[0-9;]*m/g; + +function getTestStashPath(dir: string): string { + return join(dir, ".xdg", "stash"); +} + +function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ""); +} async function runCli( cwd: string, args: string[], - env?: Record, ): Promise<{ stdout: string; stderr: string }> { return execFileAsync("node", [CLI_PATH, ...args], { cwd, - env: { ...process.env, XDG_CONFIG_HOME: join(cwd, ".xdg"), ...(env ?? {}) }, + env: { ...process.env, XDG_CONFIG_HOME: join(cwd, ".xdg") }, }); } -test("cli connect auto-inits the stash directory", async () => { - const dir = await makeTempDir("cli-connect-init"); - try { - const result = await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - assert.equal(existsSync(join(dir, ".stash")), true); - assert.equal(result.stdout.includes("Connected origin."), true); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); +async function listStashIds(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; } -}); + return (await readdir(stashPath)).filter((entry) => !entry.startsWith(".") && entry !== "config.json"); +} + +test("cli setup stores provider globals in ~/.stash/config.json", async () => { + const dir = await makeTempDir("cli-setup-global"); -test("cli connect defaults the connection name to the provider when omitted", async () => { - const dir = await makeTempDir("cli-connect-default-name"); try { - const result = await runCli(dir, [ - "connect", - "github", - "--token", - "test-token", - "--repo", - "user/repo", - ]); + const result = await runCli(dir, ["setup", "github", "--token", "test-token"]); - assert.equal(result.stdout.includes("Connected github."), true); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); + assert.equal(stripAnsi(result.stdout).includes("Configured github"), true); + const config = JSON.parse(await readFile(join(getTestStashPath(dir), "config.json"), "utf8")); assert.deepEqual(config, { - connections: { - github: { provider: "github", repo: "user/repo" }, + providers: { + github: { + token: "test-token", + }, }, }); } finally { @@ -73,265 +57,235 @@ test("cli connect defaults the connection name to the provider when omitted", as } }); -test("cli connect errors when the connection name already exists", async () => { - const dir = await makeTempDir("cli-connect-duplicate"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); +test("cli connect creates registry metadata without a local .stash directory", async () => { + const dir = await makeTempDir("cli-connect-uri"); - const result = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "origin", "--repo", "user/other-repo"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("Connection already exists: origin"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("cli connect errors when the default connection name is already taken", async () => { - const dir = await makeTempDir("cli-connect-default-duplicate"); try { - await runCli(dir, ["connect", "github", "--token", "test-token", "--repo", "user/repo"]); - - const result = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "--repo", "user/other-repo"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("Connection already exists: github"), true); + const result = await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const canonicalDir = await realpath(dir); + + assert.equal(stripAnsi(result.stdout).includes("Connected"), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalDir), true); + assert.equal(existsSync(join(dir, ".stash")), false); + + const ids = await listStashIds(getTestStashPath(dir)); + assert.equal(ids.length, 1); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), ids[0], "stash.json"), "utf8"), + ) as { path: string; connections: Array<{ provider: string; config: { id: string } }> }; + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], + }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli connect errors when adding a second connection with a different name", async () => { - const dir = await makeTempDir("cli-connect-multi"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); +test("cli connect accepts an explicit path argument", async () => { + const dir = await makeTempDir("cli-connect-explicit"); + const target = await makeTempDir("cli-connect-target"); - const result = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "backup", "--repo", "user/other"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes('already has connection "origin"'), true); - assert.equal(combined.includes("stash disconnect origin"), true); + try { + const result = await runCli(dir, ["connect", target, "github:user/repo", "--token", "test-token"]); + const canonicalTarget = await realpath(target); + + assert.equal(stripAnsi(result.stdout).includes("Connected"), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalTarget), true); + const ids = await listStashIds(getTestStashPath(dir)); + assert.equal(ids.length, 1); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), ids[0], "stash.json"), "utf8"), + ) as { path: string }; + assert.equal(record.path, await realpath(target)); } finally { await rm(dir, { recursive: true, force: true }); + await rm(target, { recursive: true, force: true }); } }); -test("cli config set/get stores allowed stash settings", async () => { - const dir = await makeTempDir("cli-config"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - await runCli(dir, ["config", "set", "allow-git", "true"]); - - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - "allow-git": true, - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }); +test("cli connect creates the target folder when it does not exist", async () => { + const dir = await makeTempDir("cli-connect-mkdir"); + const target = join(dir, "nested", "new-folder"); - const result = await runCli(dir, ["config", "get", "allow-git"]); - assert.equal(result.stdout.trim(), "true"); + try { + assert.equal(existsSync(target), false); + const result = await runCli(dir, ["connect", target, "github:user/repo", "--token", "test-token"]); + + assert.equal(stripAnsi(result.stdout).includes("Connected"), true); + assert.equal(existsSync(target), true); + const record = JSON.parse( + await readFile( + join(getTestStashPath(dir), (await listStashIds(getTestStashPath(dir)))[0], "stash.json"), + "utf8", + ), + ) as { path: string }; + assert.equal(record.path, await realpath(target)); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli config get prints nothing when value is unset", async () => { - const dir = await makeTempDir("cli-config-empty"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); +test("cli connect requires a path argument", async () => { + const dir = await makeTempDir("cli-connect-no-path"); - const result = await runCli(dir, ["config", "get", "allow-git"]); - assert.equal(result.stdout.trim(), ""); + try { + const error = await runCli(dir, ["connect", "github:user/repo"]).catch( + (value) => value as { stdout: string; stderr: string; code: number }, + ); + assert.equal(error.code, 1); + const stderr = stripAnsi(error.stderr ?? ""); + assert.equal(stderr.includes("missing required argument"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli config set rejects unknown keys", async () => { - const dir = await makeTempDir("cli-config-unknown"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - const result = await execFileAsync("node", [CLI_PATH, "config", "set", "unknown-key", "true"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); +test("cli disconnect requires a path argument", async () => { + const dir = await makeTempDir("cli-disconnect-no-path"); - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("Unknown config key"), true); + try { + const error = await runCli(dir, ["disconnect"]).catch( + (value) => value as { stdout: string; stderr: string; code: number }, + ); + assert.equal(error.code, 1); + const stderr = stripAnsi(error.stderr ?? ""); + assert.equal(stderr.includes("missing required argument"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli config commands require an initialized stash", async () => { - const dir = await makeTempDir("cli-config-missing"); +test("cli disconnect tombstones the stash record", async () => { + const dir = await makeTempDir("cli-disconnect"); + try { - const result = await execFileAsync("node", [CLI_PATH, "config", "set", "allow-git", "true"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("not a stash"), true); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const [id] = await listStashIds(getTestStashPath(dir)); + const canonicalDir = await realpath(dir); + + const result = await runCli(dir, ["disconnect", "."]); + + assert.equal(stripAnsi(result.stdout).includes("Disconnected"), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalDir), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as { connections: unknown[] }; + assert.deepEqual(record.connections, []); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli config get migrates the legacy local stash layout", async () => { - const dir = await makeTempDir("cli-config-migration"); - try { - await writeLegacyLayout(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); - - const result = await runCli(dir, ["config", "get", "allow-git"]); - assert.equal(result.stdout.trim(), ""); +test("cli relink rewrites the canonical path stored in stash.json", async () => { + const dir = await makeTempDir("cli-relink-old"); + const moved = await makeTempDir("cli-relink-new"); - await assertMigration(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); + try { + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const [id] = await listStashIds(getTestStashPath(dir)); + const canonicalDir = await realpath(dir); + const canonicalMoved = await realpath(moved); + + const result = await runCli(dir, ["relink", dir, moved]); + + assert.equal(stripAnsi(result.stdout).includes("Relinked"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalDir), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalMoved), true); + assert.equal(stripAnsi(result.stdout).includes("→"), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as { path: string }; + assert.equal(record.path, await realpath(moved)); } finally { await rm(dir, { recursive: true, force: true }); + await rm(moved, { recursive: true, force: true }); } }); -test("cli sync failure sets exit code 1 and prints error once", async () => { - const dir = await makeTempDir("cli-sync-fail"); +test("cli status prints registered stashes from the registry root", async () => { + const dir = await makeTempDir("cli-status"); + const stashPath = getTestStashPath(dir); + try { - const result = await execFileAsync("node", [CLI_PATH, "sync"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const [id] = await listStashIds(stashPath); + await writeFile( + join(stashPath, id, "status.json"), + JSON.stringify( + { + kind: "idle", + lastSync: "2026-03-11T14:30:00.000Z", + summary: null, + error: null, + }, + null, + 2, + ), + "utf8", + ); - assert.equal(result.code, 1, "process should exit with code 1"); + const result = await runCli(dir, ["status"]); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - const errorOccurrences = combined.split("sync failed").length - 1; - assert.equal(errorOccurrences <= 1, true, `error should appear at most once, got: ${combined}`); + assert.equal(stripAnsi(result.stdout).includes("Daemon:"), true); + assert.equal(stripAnsi(result.stdout).includes("Connection"), true); + assert.equal(stripAnsi(result.stdout).includes("State"), true); + assert.equal(stripAnsi(result.stdout).includes(await realpath(dir)), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli sync in a git repository prints the allow-git guidance", async () => { - const dir = await makeTempDir("cli-sync-git"); - const xdg = await makeTempDir("cli-sync-git-xdg"); +test("cli connect blocks git repos unless --dangerously-allow-git is passed", async () => { + const dir = await makeTempDir("cli-connect-git"); + try { await mkdir(join(dir, ".git"), { recursive: true }); - await runCli( - dir, - ["connect", "github", "origin", "--token", "test-token", "--repo", "user/repo"], + + const error = await execFileAsync( + "node", + [CLI_PATH, "connect", ".", "github:user/repo", "--token", "test-token"], { - XDG_CONFIG_HOME: xdg, + cwd: dir, + env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, }, - ); + ).catch((value) => value as { stdout: string; stderr: string; code: number }); + const stderr = stripAnsi(error.stderr ?? ""); - const result = await execFileAsync("node", [CLI_PATH, "sync"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: xdg }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); + assert.equal(error.code, 1); + assert.equal(stderr.includes("Error:"), true); + assert.equal(stderr.includes("is a git repository"), true); + assert.equal(stderr.includes("--dangerously-allow-git"), true); - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal( - combined.includes( - "git repository detected — run `stash config set allow-git true` to allow syncing", - ), - true, - ); + const allowed = await runCli(dir, [ + "connect", + ".", + "github:user/repo", + "--token", + "test-token", + "--dangerously-allow-git", + ]); + assert.equal(stripAnsi(allowed.stdout).includes("Connected"), true); + assert.equal(stripAnsi(allowed.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(allowed.stdout).includes(await realpath(dir)), true); } finally { await rm(dir, { recursive: true, force: true }); - await rm(xdg, { recursive: true, force: true }); } }); -test("cli sync migrates the legacy local stash layout before a no-op sync", async () => { - const dir = await makeTempDir("cli-sync-migration"); - try { - await writeLegacyLayout(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); +test("cli output ends with a trailing blank line so the shell prompt has breathing room", async () => { + const dir = await makeTempDir("cli-trailing-blank"); - const result = await runCli(dir, ["sync"]); - assert.equal(result.stdout.includes("up to date"), true); + try { + const connect = await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + assert.equal(connect.stdout.endsWith("\n\n"), true, "connect stdout should end with a blank line"); - await assertMigration(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); + const status = await runCli(dir, ["status"]); + assert.equal(status.stdout.endsWith("\n\n"), true, "status stdout should end with a blank line"); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/integration/stash-config.integration.test.ts b/tests/integration/stash-config.integration.test.ts index e0c8e5f..04322e8 100644 --- a/tests/integration/stash-config.integration.test.ts +++ b/tests/integration/stash-config.integration.test.ts @@ -5,23 +5,27 @@ import { join } from "node:path"; import { MultipleConnectionsError } from "../../src/errors.ts"; import { makeStash } from "../helpers/make-stash.ts"; -test("integration config: connect writes config.json", async () => { - const { stash, dir } = await makeStash(); +test("integration config: connect writes stash.json", async () => { + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { - origin: { provider: "github", repo: "user/repo" }, + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, [ + { + provider: "github", + config: { + name: "origin", + repo: "user/repo", + }, }, - }); + ]); }); -test("integration config: disconnect removes connection", async () => { - const { stash, dir } = await makeStash(); +test("integration config: disconnect removes the stash connection", async () => { + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); await stash.disconnect("origin"); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { connections: {} }); + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, []); }); test("integration config: connect rejects a second connection with a different name", async () => { @@ -34,11 +38,19 @@ test("integration config: connect rejects a second connection with a different n }); test("integration config: connect allows reconnecting the same name", async () => { - const { stash, dir } = await makeStash(); + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); await stash.connect({ name: "origin", provider: "github", repo: "user/updated" }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config.connections.origin, { provider: "github", repo: "user/updated" }); + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, [ + { + provider: "github", + config: { + name: "origin", + repo: "user/updated", + }, + }, + ]); }); test("integration config: config getter merges global and local by provider type", async () => { diff --git a/tests/integration/stash-factory.integration.test.ts b/tests/integration/stash-factory.integration.test.ts new file mode 100644 index 0000000..c2178c4 --- /dev/null +++ b/tests/integration/stash-factory.integration.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getStashDirPath } from "../../src/global-config.ts"; +import { Stash } from "../../src/stash.ts"; + +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +async function withTempHome(prefix: string, run: (home: string) => Promise): Promise { + const home = await makeTempDir(prefix); + const previousHome = process.env.HOME; + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + + try { + return await run(home); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + await rm(home, { recursive: true, force: true }); + } +} + +test("stash factories: create writes stash.json under ~/.stash/ and load reads it back", async () => { + await withTempHome("stash-factory", async () => { + const dir = await makeTempDir("stash-local"); + const globalConfig = { providers: {}, background: { stashes: [] } }; + const record = { + path: dir, + connections: [{ provider: "github", config: { id: "user/repo" } }], + }; + + try { + const created = await Stash.create(record, globalConfig); + + assert.match(created.id, /^[0-9A-HJKMNP-TV-Z]{26}$/); + assert.equal(existsSync(join(dir, ".stash")), false); + + const stashDir = getStashDirPath(created.id); + assert.equal(existsSync(stashDir), true); + assert.deepEqual(JSON.parse(await readFile(join(stashDir, "stash.json"), "utf8")), record); + + const loaded = await Stash.load(created.id, globalConfig); + assert.equal(loaded.id, created.id); + assert.deepEqual(loaded.record, record); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/integration/stash-layout.integration.test.ts b/tests/integration/stash-layout.integration.test.ts new file mode 100644 index 0000000..f191b83 --- /dev/null +++ b/tests/integration/stash-layout.integration.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + ensureStashDirPath, + getGlobalConfigPath, + getStashDirPath, + getStashPath, + writeGlobalConfig, +} from "../../src/global-config.ts"; +import { getRegistryLockPath, withRegistryLock } from "../../src/registry-lock.ts"; + +async function makeTempHome(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +async function withTempHome(prefix: string, run: (home: string) => Promise): Promise { + const home = await makeTempHome(prefix); + const previousHome = process.env.HOME; + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + + try { + return await run(home); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + await rm(home, { recursive: true, force: true }); + } +} + +test("stash layout: global config writes under ~/.stash with 0700 root, 0600 config, and provider-only JSON", async () => { + await withTempHome("stash-layout-config", async (home) => { + await writeGlobalConfig({ + providers: { + github: { token: "secret" }, + }, + background: { + stashes: ["/tmp/ignored-by-new-layout"], + }, + }); + + const stashPath = join(home, ".stash"); + const configPath = join(stashPath, "config.json"); + + assert.equal(getStashPath(), stashPath); + assert.equal(getGlobalConfigPath(), configPath); + assert.equal((await stat(stashPath)).mode & 0o777, 0o700); + assert.equal((await stat(configPath)).mode & 0o777, 0o600); + assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")), { + providers: { + github: { token: "secret" }, + }, + }); + }); +}); + +test("stash layout: per-stash metadata directories live under ~/.stash keyed by ULID", async () => { + await withTempHome("stash-layout-dir", async (home) => { + const id = "01HF7E2K3Y4Z5N8M9PQRSTVWXY"; + const stashDir = await ensureStashDirPath(id); + + assert.equal(stashDir, join(home, ".stash", id)); + assert.equal(getStashDirPath(id), stashDir); + assert.equal(basename(stashDir), id); + assert.equal(existsSync(stashDir), true); + assert.equal((await stat(stashDir)).mode & 0o777, 0o700); + }); +}); + +test("stash layout: withRegistryLock uses ~/.stash/.lock and serializes callers", async () => { + await withTempHome("stash-layout-lock", async (home) => { + const events: string[] = []; + let releaseFirst: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withRegistryLock(async () => { + events.push("first:start"); + assert.equal(getRegistryLockPath(), join(home, ".stash", ".lock")); + assert.equal(existsSync(getRegistryLockPath()), true); + assert.equal((await stat(getRegistryLockPath())).mode & 0o777, 0o600); + await gate; + events.push("first:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + const second = withRegistryLock(async () => { + events.push("second:start"); + events.push("second:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.deepEqual(events, ["first:start"]); + + releaseFirst?.(); + await Promise.all([first, second]); + + assert.deepEqual(events, ["first:start", "first:end", "second:start", "second:end"]); + assert.equal(existsSync(getRegistryLockPath()), false); + }); +}); + +test("stash layout: withRegistryLock reclaims a lock older than 60 seconds", async () => { + await withTempHome("stash-layout-stale", async () => { + const lockPath = getRegistryLockPath(); + await mkdir(getStashPath(), { recursive: true }); + await writeFile( + lockPath, + JSON.stringify( + { + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: "test-host", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + const old = new Date(Date.now() - 2 * 60_000); + await utimes(lockPath, old, old); + + let acquired = false; + await withRegistryLock(async () => { + acquired = true; + }); + + assert.equal(acquired, true); + assert.equal(existsSync(lockPath), false); + }); +}); diff --git a/tests/integration/stash-sync.test.ts b/tests/integration/stash-sync.test.ts index c6ef672..c251190 100644 --- a/tests/integration/stash-sync.test.ts +++ b/tests/integration/stash-sync.test.ts @@ -1,15 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync, readdirSync } from "node:fs"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { readFile, rename, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { hashBuffer } from "../../src/utils/hash.ts"; -import { - GitRepoError, - MultipleConnectionsError, - PushConflictError, - SyncLockError, -} from "../../src/errors.ts"; +import { MultipleConnectionsError, PushConflictError, SyncLockError } from "../../src/errors.ts"; import { FakeProvider } from "../helpers/fake-provider.ts"; import { makeStash, writeFiles } from "../helpers/make-stash.ts"; @@ -34,7 +29,7 @@ function deferred() { test("sync: first sync pushes all local files", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello", "notes/todo.md": "buy milk" }, { providers: fakeRegistry(fake) }, ); @@ -46,9 +41,9 @@ test("sync: first sync pushes all local files", async () => { assert.equal(fake.files.get("notes/todo.md"), "buy milk"); assert.equal(typeof fake.snapshot["hello.md"]?.hash, "string"); - const localSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.deepEqual(localSnapshot, fake.snapshot); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "hello.md"), "utf8"), "hello"); + assert.equal(await readFile(join(metadataDir, "snapshot", "hello.md"), "utf8"), "hello"); }); test("sync: first sync pulls all remote files", async () => { @@ -62,14 +57,14 @@ test("sync: first sync pulls all remote files", async () => { "data/config.json": { hash: hashBuffer(Buffer.from("{}", "utf8")) }, }, }); - const { stash, dir } = await makeStash({}, { providers: fakeRegistry(fake) }); + const { stash, dir, metadataDir } = await makeStash({}, { providers: fakeRegistry(fake) }); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); assert.equal(await readFile(join(dir, "readme.md"), "utf8"), "welcome"); assert.equal(await readFile(join(dir, "data", "config.json"), "utf8"), "{}"); - const snapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.deepEqual(snapshot, fake.snapshot); }); @@ -191,7 +186,7 @@ test("sync: single-flight guard rejects concurrent sync", async () => { test("sync: snapshot writes text files and skips binary files", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "text.md": "hello", "img.bin": Buffer.from([0xff, 0xfe, 0x00]), @@ -201,23 +196,23 @@ test("sync: snapshot writes text files and skips binary files", async () => { await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "text.md"), "utf8"), "hello"); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "img.bin")), false); + assert.equal(await readFile(join(metadataDir, "snapshot", "text.md"), "utf8"), "hello"); + assert.equal(existsSync(join(metadataDir, "snapshot", "img.bin")), false); }); test("sync: deleting a file removes it from snapshot", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "hello.md")), true); + assert.equal(existsSync(join(metadataDir, "snapshot", "hello.md")), true); await unlink(join(dir, "hello.md")); await stash.sync(); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "hello.md")), false); + assert.equal(existsSync(join(metadataDir, "snapshot", "hello.md")), false); }); test("sync: first sync with identical content on both sides skips file writes", async () => { @@ -231,7 +226,7 @@ test("sync: first sync with identical content on both sides skips file writes", "notes/todo.md": { hash: hashBuffer(Buffer.from("buy milk", "utf8")) }, }, }); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "hello.md": "hello", "notes/todo.md": "buy milk" }, { providers: fakeRegistry(fake) }, ); @@ -247,49 +242,35 @@ test("sync: first sync with identical content on both sides skips file writes", assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); assert.equal(await readFile(join(dir, "notes/todo.md"), "utf8"), "buy milk"); - const localSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(localSnapshot["hello.md"]?.hash, hashBuffer(Buffer.from("hello", "utf8"))); assert.equal(localSnapshot["notes/todo.md"]?.hash, hashBuffer(Buffer.from("buy milk", "utf8"))); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "hello.md"), "utf8"), "hello"); + assert.equal(await readFile(join(metadataDir, "snapshot", "hello.md"), "utf8"), "hello"); assert.equal( - await readFile(join(dir, ".stash", "snapshot", "notes/todo.md"), "utf8"), + await readFile(join(metadataDir, "snapshot", "notes/todo.md"), "utf8"), "buy milk", ); }); -test("sync: git repository without allow-git throws", async () => { - const fake = new FakeProvider(); - const { stash, dir } = await makeStash( - { "hello.md": "hello" }, - { providers: fakeRegistry(fake) }, - ); - await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - await mkdir(join(dir, ".git"), { recursive: true }); - - await assert.rejects(stash.sync(), GitRepoError); - assert.equal(fake.fetchCalls, 0); - assert.equal(fake.pushCalls, 0); -}); - test("sync: multiple connections throws", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir, stashPath } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "first", provider: "fake", repo: "r1" }); // Bypass the connect guard by writing a second connection directly - const configPath = join(dir, ".stash", "config.json"); - const config = JSON.parse(await readFile(configPath, "utf8")); - config.connections.second = { provider: "fake", repo: "r2" }; - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + const recordPath = join(metadataDir, "stash.json"); + const record = JSON.parse(await readFile(recordPath, "utf8")); + record.connections.push({ provider: "fake", config: { name: "second", repo: "r2" } }); + await writeFile(recordPath, JSON.stringify(record, null, 2), "utf8"); // Reload to pick up both connections const { Stash } = await import("../../src/stash.ts"); const reloaded = await Stash.load( - dir, + stash.id, { providers: {}, background: { stashes: [] } }, - { providers: fakeRegistry(fake) }, + { providers: fakeRegistry(fake), stashPath }, ); await assert.rejects(reloaded.sync(), MultipleConnectionsError); @@ -297,31 +278,9 @@ test("sync: multiple connections throws", async () => { assert.equal(fake.pushCalls, 0); }); -test("sync: allow-git permits syncing inside a git repository", async () => { - const fake = new FakeProvider(); - const { stash, dir } = await makeStash( - { "hello.md": "hello" }, - { providers: fakeRegistry(fake) }, - ); - await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { "allow-git": true, connections: { fake: { provider: "fake", repo: "r" } } }, - null, - 2, - ), - "utf8", - ); - await mkdir(join(dir, ".git"), { recursive: true }); - - await stash.sync(); - assert.equal(fake.files.get("hello.md"), "hello"); -}); - test("sync: skip/skip mutations with changed snapshot still pushes snapshot", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -341,7 +300,7 @@ test("sync: skip/skip mutations with changed snapshot still pushes snapshot", as assert.equal(lastPush.files.size, 0, "no file content should be pushed"); assert.deepEqual(lastPush.deletions, [], "no deletions should be pushed"); - const localSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(localSnapshot["hello.md"]?.hash, hashBuffer(Buffer.from("updated", "utf8"))); }); @@ -388,7 +347,10 @@ test("sync: preserves local edits made after scan but before push (pre-push race const bobRemote = "line1\nline2\nline3\nBOB_END\n"; const fake = new FakeProvider(); - const { stash, dir } = await makeStash({ "doc.md": baseline }, { providers: fakeRegistry(fake) }); + const { stash, dir } = await makeStash( + { "doc.md": baseline }, + { providers: fakeRegistry(fake) }, + ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); @@ -429,7 +391,10 @@ test("sync: preserves local edits made after push but before apply (post-push ra const bobRemote = "line1\nline2\nline3\nBOB_END\n"; const fake = new FakeProvider(); - const { stash, dir } = await makeStash({ "doc.md": baseline }, { providers: fakeRegistry(fake) }); + const { stash, dir } = await makeStash( + { "doc.md": baseline }, + { providers: fakeRegistry(fake) }, + ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); @@ -473,7 +438,10 @@ test("sync: drift retries are bounded and failed cycle does not apply/save", asy const bobRemote = "line1\nline2\nline3\nBOB_END\n"; const fake = new FakeProvider(); - const { stash, dir } = await makeStash({ "doc.md": baseline }, { providers: fakeRegistry(fake) }); + const { stash, dir, metadataDir } = await makeStash( + { "doc.md": baseline }, + { providers: fakeRegistry(fake) }, + ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); @@ -481,7 +449,7 @@ test("sync: drift retries are bounded and failed cycle does not apply/save", asy fake.snapshot["doc.md"] = { hash: hashBuffer(Buffer.from(bobRemote, "utf8")) }; await writeFiles(dir, { "doc.md": aliceEarly }); - const beforeSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const beforeSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); fake.fetchCalls = 0; fake.pushCalls = 0; @@ -492,13 +460,13 @@ test("sync: drift retries are bounded and failed cycle does not apply/save", asy assert.equal(fake.pushCalls, 0); assert.equal(await readFile(join(dir, "doc.md"), "utf8"), aliceEarly); - const afterSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const afterSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.deepEqual(afterSnapshot, beforeSnapshot); }); test("sync: case-only rename syncs successfully", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "notes/Arabella.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -518,7 +486,7 @@ test("sync: case-only rename syncs successfully", async () => { // Old-case path should be gone from remote assert.equal(fake.files.has("notes/Arabella.md"), false); // Snapshot updated to new casing - const snapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.ok(snapshot["notes/arabella.md"]); assert.equal(snapshot["notes/Arabella.md"], undefined); }); @@ -620,7 +588,7 @@ test("sync: nested directory case rename applies correctly", async () => { test("sync: true deletion still works alongside casing check", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "notes/Arabella.md": "hello", "other.md": "keep" }, { providers: fakeRegistry(fake) }, ); @@ -633,6 +601,6 @@ test("sync: true deletion still works alongside casing check", async () => { assert.equal(fake.files.has("notes/Arabella.md"), false); assert.equal(fake.files.get("other.md"), "keep"); - const snapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(snapshot["notes/Arabella.md"], undefined); }); diff --git a/tests/unit/daemon.test.ts b/tests/unit/daemon.test.ts index e027197..3563eae 100644 --- a/tests/unit/daemon.test.ts +++ b/tests/unit/daemon.test.ts @@ -1,161 +1,188 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { BackgroundDaemon } from "../../src/daemon.ts"; -import type { GlobalConfig } from "../../src/types.ts"; -import type { WatchStatus } from "../../src/watch.ts"; +import type { GlobalConfig, StashRecord } from "../../src/types.ts"; -type ConfigEvent = { +type WatchEvent = { path: string; type: "create" | "update" | "delete"; }; +type WatchCallback = (error: Error | null, events: WatchEvent[]) => void; + async function makeTempDir(prefix: string): Promise { return mkdtemp(join(tmpdir(), `${prefix}-`)); } -test("daemon: starts registered watches, writes status.json, and hot-reloads config changes", async () => { - const workspace = await makeTempDir("stash-daemon"); - const stashA = join(workspace, "stash-a"); - const stashB = join(workspace, "stash-b"); - const configPath = join(workspace, "config.json"); +async function writeConfig(configPath: string, providers: GlobalConfig["providers"]): Promise { + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify({ providers }, null, 2), "utf8"); +} + +async function writeStashRecord( + stashPath: string, + id: string, + record: StashRecord, +): Promise { + await mkdir(join(stashPath, id), { recursive: true }); + await writeFile(join(stashPath, id, "stash.json"), JSON.stringify(record, null, 2), "utf8"); +} + +async function waitFor( + check: () => boolean, + message: string, + timeoutMs = 500, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) { + return; + } + await delay(10); + } + assert.fail(message); +} + +test("daemon: reconciles registry entries, restarts on tuple changes, and stops removed stashes", async () => { + const workspace = await makeTempDir("stash-daemon-registry"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const dirA = join(workspace, "dir-a"); + const dirB = join(workspace, "dir-b"); + let daemon: BackgroundDaemon | null = null; const started: string[] = []; const stopped: string[] = []; const logs: string[] = []; - const watchCallbacks = new Map Promise>(); - let config: GlobalConfig = { - providers: {}, - background: { - stashes: [stashA], - }, - }; - let onConfigEvent: ((error: Error | null, events: ConfigEvent[]) => void) | null = null; + const callbacks = new Map(); try { - await mkdir(join(stashA, ".stash"), { recursive: true }); - await mkdir(join(stashB, ".stash"), { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "stash-a", { + path: dirA, + connections: [{ provider: "github", config: { id: "user/repo-a" } }], + }); - const daemon = new BackgroundDaemon({ + daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => config, + pollMs: 20, log: (message) => logs.push(message), - subscribe: async (_path, callback) => { - onConfigEvent = callback as typeof onConfigEvent; + subscribe: async (path, callback) => { + callbacks.set(path, callback as WatchCallback); return { - unsubscribe: async () => {}, - }; - }, - createWatch: async ({ dir, onStatus }) => { - watchCallbacks.set(dir, onStatus); - return { - start: async () => { - started.push(dir); - await onStatus({ - kind: "checked", - lastSync: new Date("2026-03-11T14:30:00.000Z"), - nextCheck: new Date("2026-03-11T14:31:00.000Z"), - error: null, - }); - }, - stop: async () => { - stopped.push(dir); + unsubscribe: async () => { + callbacks.delete(path); }, }; }, + createWatch: async ({ stash, onStatus }) => ({ + start: async () => { + started.push(stash.id); + await onStatus({ + kind: "checked", + lastSync: new Date("2026-03-11T14:30:00.000Z"), + nextCheck: new Date("2026-03-11T14:31:00.000Z"), + error: null, + }); + }, + stop: async () => { + stopped.push(stash.id); + }, + }), }); await daemon.start(); - const statusPath = join(stashA, ".stash", "status.json"); - assert.equal(existsSync(statusPath), true); - assert.deepEqual(JSON.parse(await readFile(statusPath, "utf8")), { + assert.deepEqual(started, ["stash-a"]); + assert.deepEqual(JSON.parse(await readFile(join(stashPath, "stash-a", "status.json"), "utf8")), { kind: "checked", lastSync: "2026-03-11T14:30:00.000Z", summary: null, error: null, }); - const logPath = join(stashA, ".stash", "sync.log"); - assert.equal((await readFile(logPath, "utf8")).includes("checked"), true); - - config = { - providers: {}, - background: { - stashes: [stashB], - }, - }; - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); - onConfigEvent?.(null, [{ type: "update", path: configPath }]); - await delay(20); + await writeConfig(configPath, { + github: { token: "token-b" }, + }); + callbacks.get(configPath)?.(null, [{ type: "update", path: configPath }]); + await waitFor( + () => started.length >= 2 && stopped.length >= 1, + "expected stash-a to restart after provider config changed", + ); - assert.deepEqual(started, [stashA, stashB]); - assert.deepEqual(stopped, [stashA]); + assert.deepEqual(started, ["stash-a", "stash-a"]); + assert.deepEqual(stopped, ["stash-a"]); - assert.ok( - logs.some((m) => m.includes(`watching ${stashA}`)), - "should log when starting a watch", - ); - assert.ok( - logs.some((m) => m.includes(`stopped watching ${stashA}`)), - "should log when stopping a watch", + await writeStashRecord(stashPath, "stash-b", { + path: dirB, + connections: [{ provider: "github", config: { id: "user/repo-b" } }], + }); + callbacks.get(stashPath)?.(null, [{ type: "create", path: join(stashPath, "stash-b") }]); + await waitFor( + () => started.length >= 3, + "expected stash-b to start after being added to the registry", ); - assert.ok( - logs.some((m) => m.includes(`watching ${stashB}`)), - "should log new watch after reload", + + assert.deepEqual(started, ["stash-a", "stash-a", "stash-b"]); + + await rm(join(stashPath, "stash-a"), { recursive: true, force: true }); + callbacks.get(stashPath)?.(null, [{ type: "delete", path: join(stashPath, "stash-a") }]); + await waitFor( + () => stopped.length >= 2, + "expected stash-a to stop after being removed from the registry", ); - await daemon.stop(); + assert.deepEqual(stopped, ["stash-a", "stash-a"]); + assert.ok(logs.some((message) => message.includes("watching stash-a"))); + assert.ok(logs.some((message) => message.includes("watching stash-b"))); + assert.ok(logs.some((message) => message.includes("stopped watching stash-a"))); + } finally { + await daemon?.stop(); await rm(workspace, { recursive: true, force: true }); } }); -test("daemon: caps sync.log at 1MB by truncating the oldest lines", async () => { - const workspace = await makeTempDir("stash-daemon-log"); - const stashDir = join(workspace, "stash"); - const configPath = join(workspace, "config.json"); - const oldLine = "x".repeat(200); - const oldLog = `old-start\n${`${oldLine}\n`.repeat(7_000)}old-tail\n`; +test("daemon: poll-only mode still reconciles new registry entries", async () => { + const workspace = await makeTempDir("stash-daemon-poll"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const dirA = join(workspace, "dir-a"); + const dirB = join(workspace, "dir-b"); + const started: string[] = []; + const logs: string[] = []; try { - await mkdir(join(stashDir, ".stash"), { recursive: true }); - await writeFile(join(stashDir, ".stash", "sync.log"), oldLog, "utf8"); - await writeFile( - configPath, - JSON.stringify( - { - providers: {}, - background: { - stashes: [stashDir], - }, - } satisfies GlobalConfig, - null, - 2, - ), - "utf8", - ); + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "stash-a", { + path: dirA, + connections: [{ provider: "github", config: { id: "user/repo-a" } }], + }); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => ({ - providers: {}, - background: { - stashes: [stashDir], - }, - }), - subscribe: async () => ({ - unsubscribe: async () => {}, - }), - createWatch: async ({ onStatus }) => ({ + pollMs: 20, + log: (message) => logs.push(message), + subscribe: async () => { + throw new Error("watch unavailable"); + }, + createWatch: async ({ stash, onStatus }) => ({ start: async () => { + started.push(stash.id); await onStatus({ - kind: "synced", - summary: "1↑ 2↓", + kind: "checked", lastSync: new Date("2026-03-11T14:30:00.000Z"), nextCheck: new Date("2026-03-11T14:31:00.000Z"), error: null, @@ -166,49 +193,142 @@ test("daemon: caps sync.log at 1MB by truncating the oldest lines", async () => }); await daemon.start(); - await daemon.stop(); + assert.deepEqual(started, ["stash-a"]); - const logPath = join(stashDir, ".stash", "sync.log"); - const log = await readFile(logPath, "utf8"); - assert.ok(Buffer.byteLength(log, "utf8") <= 1_000_000); - assert.equal(log.includes("1↑ 2↓"), true); - assert.equal(log.includes("old-start"), false); + await writeStashRecord(stashPath, "stash-b", { + path: dirB, + connections: [{ provider: "github", config: { id: "user/repo-b" } }], + }); + await delay(60); + + assert.deepEqual(started, ["stash-a", "stash-b"]); + assert.ok(logs.some((message) => message.includes("watch unavailable"))); + + await daemon.stop(); } finally { await rm(workspace, { recursive: true, force: true }); } }); -test("daemon: continues in poll-only mode when native watcher throws", async () => { - const workspace = await makeTempDir("stash-daemon-no-watcher"); - const stashDir = join(workspace, "stash"); - const configPath = join(workspace, "config.json"); - const logs: string[] = []; - let started = false; +test("daemon: queues one immediate follow-up reload when changes arrive mid-reconcile", async () => { + const workspace = await makeTempDir("stash-daemon-pending-reload"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const dirA = join(workspace, "dir-a"); + const dirB = join(workspace, "dir-b"); + const started: string[] = []; + const stopped: string[] = []; + const callbacks = new Map(); + let releaseStop: (() => void) | null = null; + let markStopEntered: (() => void) | null = null; + const stopEntered = new Promise((resolve) => { + markStopEntered = resolve; + }); + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); try { - await mkdir(join(stashDir, ".stash"), { recursive: true }); - await writeFile( - configPath, - JSON.stringify({ - providers: {}, - background: { stashes: [stashDir] }, - } satisfies GlobalConfig), - "utf8", - ); + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "stash-a", { + path: dirA, + connections: [{ provider: "github", config: { id: "user/repo-a" } }], + }); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => ({ - providers: {}, - background: { stashes: [stashDir] }, - }), - log: (message) => logs.push(message), - subscribe: async () => { - throw new Error("inotify limit reached"); + pollMs: 10_000, + subscribe: async (path, callback) => { + callbacks.set(path, callback as WatchCallback); + return { + unsubscribe: async () => { + callbacks.delete(path); + }, + }; }, - createWatch: async ({ onStatus }) => ({ + createWatch: async ({ stash }) => ({ start: async () => { - started = true; + started.push(stash.id); + }, + stop: async () => { + stopped.push(stash.id); + if (stash.id === "stash-a" && stopped.length === 1) { + markStopEntered?.(); + await stopGate; + } + }, + }), + }); + + await daemon.start(); + assert.deepEqual(started, ["stash-a"]); + + await writeConfig(configPath, { + github: { token: "token-b" }, + }); + callbacks.get(configPath)?.(null, [{ type: "update", path: configPath }]); + await stopEntered; + + await writeStashRecord(stashPath, "stash-b", { + path: dirB, + connections: [{ provider: "github", config: { id: "user/repo-b" } }], + }); + callbacks.get(stashPath)?.(null, [{ type: "create", path: join(stashPath, "stash-b") }]); + + releaseStop?.(); + await delay(80); + + assert.deepEqual(stopped, ["stash-a"]); + assert.deepEqual(started, ["stash-a", "stash-a", "stash-b"]); + + await daemon.stop(); + } finally { + releaseStop?.(); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("daemon: writes missing status and revalidates tombstone deletion after stop", async () => { + const workspace = await makeTempDir("stash-daemon-missing"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const liveDir = join(workspace, "live-dir"); + const missingDir = join(workspace, "missing-dir"); + let daemon: BackgroundDaemon | null = null; + const started: string[] = []; + const stopStarted: string[] = []; + let releaseStop: (() => void) | null = null; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + + try { + await mkdir(liveDir, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "missing-stash", { + path: missingDir, + connections: [{ provider: "github", config: { id: "user/missing" } }], + }); + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [{ provider: "github", config: { id: "user/live" } }], + }); + + daemon = new BackgroundDaemon({ + configPath, + pollMs: 20, + subscribe: async () => ({ + unsubscribe: async () => {}, + }), + createWatch: async ({ stash, onStatus }) => ({ + start: async () => { + started.push(stash.id); await onStatus({ kind: "checked", lastSync: new Date("2026-03-11T14:30:00.000Z"), @@ -216,61 +336,162 @@ test("daemon: continues in poll-only mode when native watcher throws", async () error: null, }); }, - stop: async () => {}, + stop: async () => { + stopStarted.push(stash.id); + await stopGate; + }, }), }); await daemon.start(); - assert.equal(started, true, "watch should still start despite watcher failure"); - assert.ok( - logs.some((m) => m.includes("native config watcher unavailable")), - "should log watcher fallback", + assert.deepEqual(started, ["live-stash"]); + assert.deepEqual( + JSON.parse(await readFile(join(stashPath, "missing-stash", "status.json"), "utf8")), + { + kind: "missing", + lastSync: null, + summary: null, + error: null, + }, ); - const statusPath = join(stashDir, ".stash", "status.json"); - assert.equal(existsSync(statusPath), true, "status.json should still be written"); + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [], + }); + await delay(40); + + assert.deepEqual(stopStarted, ["live-stash"]); + + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [{ provider: "github", config: { id: "user/live" } }], + }); + releaseStop?.(); + await delay(80); + + assert.equal(existsSync(join(stashPath, "live-stash")), true); + assert.deepEqual(started, ["live-stash", "live-stash"]); - await daemon.stop(); } finally { + releaseStop?.(); + await daemon?.stop(); await rm(workspace, { recursive: true, force: true }); } }); -test("daemon: persists git safety errors to status.json and sync.log", async () => { - const workspace = await makeTempDir("stash-daemon-git-error"); - const stashDir = join(workspace, "stash"); - const configPath = join(workspace, "config.json"); - const errorMessage = - "git repository detected — run `stash config set allow-git true` to allow syncing"; +test("daemon: reload GC removes stale orphan dirs added after startup", async () => { + const workspace = await makeTempDir("stash-daemon-reload-orphan"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const callbacks = new Map(); try { - await mkdir(join(stashDir, ".stash"), { recursive: true }); - await writeFile( + await writeConfig(configPath, {}); + + const daemon = new BackgroundDaemon({ configPath, - JSON.stringify({ - providers: {}, - background: { stashes: [stashDir] }, - } satisfies GlobalConfig), - "utf8", - ); + pollMs: 10_000, + subscribe: async (path, callback) => { + callbacks.set(path, callback as WatchCallback); + return { + unsubscribe: async () => { + callbacks.delete(path); + }, + }; + }, + createWatch: async () => { + throw new Error("no watches expected"); + }, + }); + + await daemon.start(); + + const orphanDir = join(stashPath, "old-orphan"); + await mkdir(orphanDir, { recursive: true }); + const oldDate = new Date(Date.now() - 10 * 60_000); + await utimes(orphanDir, oldDate, oldDate); + + callbacks.get(stashPath)?.(null, [{ type: "create", path: orphanDir }]); + await delay(80); + + assert.equal(existsSync(orphanDir), false); + + await daemon.stop(); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("daemon: startup GC removes old incomplete dirs and keeps fresh incomplete dirs", async () => { + const workspace = await makeTempDir("stash-daemon-gc"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const oldDir = join(stashPath, "old-orphan"); + const freshDir = join(stashPath, "fresh-orphan"); + + try { + await mkdir(oldDir, { recursive: true }); + await mkdir(freshDir, { recursive: true }); + const oldDate = new Date(Date.now() - 10 * 60_000); + await utimes(oldDir, oldDate, oldDate); + await writeConfig(configPath, {}); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => ({ - providers: {}, - background: { stashes: [stashDir] }, + pollMs: 20, + subscribe: async () => ({ + unsubscribe: async () => {}, }), + createWatch: async () => { + throw new Error("no watches expected"); + }, + }); + + await daemon.start(); + await daemon.stop(); + + assert.equal(existsSync(oldDir), false); + assert.equal(existsSync(freshDir), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("daemon: caps sync.log at 1MB under per-stash metadata dirs", async () => { + const workspace = await makeTempDir("stash-daemon-log"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const liveDir = join(workspace, "live-dir"); + const oldLine = "x".repeat(200); + const oldLog = `old-start\n${`${oldLine}\n`.repeat(7_000)}old-tail\n`; + + try { + await mkdir(liveDir, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [{ provider: "github", config: { id: "user/live" } }], + }); + await writeFile(join(stashPath, "live-stash", "sync.log"), oldLog, "utf8"); + + const daemon = new BackgroundDaemon({ + configPath, + pollMs: 20, subscribe: async () => ({ unsubscribe: async () => {}, }), createWatch: async ({ onStatus }) => ({ start: async () => { await onStatus({ - kind: "error", - error: errorMessage, - lastSync: null, + kind: "synced", + summary: "1↑ 2↓", + lastSync: new Date("2026-03-11T14:30:00.000Z"), nextCheck: new Date("2026-03-11T14:31:00.000Z"), + error: null, }); }, stop: async () => {}, @@ -280,16 +501,11 @@ test("daemon: persists git safety errors to status.json and sync.log", async () await daemon.start(); await daemon.stop(); - const status = JSON.parse(await readFile(join(stashDir, ".stash", "status.json"), "utf8")); - assert.deepEqual(status, { - kind: "error", - lastSync: null, - summary: null, - error: errorMessage, - }); - - const log = await readFile(join(stashDir, ".stash", "sync.log"), "utf8"); - assert.equal(log.includes(errorMessage), true); + const logPath = join(stashPath, "live-stash", "sync.log"); + const log = await readFile(logPath, "utf8"); + assert.ok(Buffer.byteLength(log, "utf8") <= 1_000_000); + assert.equal(log.includes("1↑ 2↓"), true); + assert.equal(log.includes("old-start"), false); } finally { await rm(workspace, { recursive: true, force: true }); } diff --git a/tests/unit/fs.test.ts b/tests/unit/fs.test.ts new file mode 100644 index 0000000..b69581c --- /dev/null +++ b/tests/unit/fs.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeJsonFile } from "../../src/utils/fs.ts"; + +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +test("writeJsonFile writes JSON atomically with the requested mode", async () => { + const dir = await makeTempDir("stash-fs"); + const path = join(dir, "nested", "config.json"); + + try { + await writeJsonFile(path, { hello: "world" }, { mode: 0o600 }); + + assert.equal(await readFile(path, "utf8"), '{\n "hello": "world"\n}'); + assert.equal(existsSync(`${path}.tmp`), false); + assert.equal((await stat(path)).mode & 0o777, 0o600); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/github-provider.test.ts b/tests/unit/github-provider.test.ts index 5d62b08..25d4ffc 100644 --- a/tests/unit/github-provider.test.ts +++ b/tests/unit/github-provider.test.ts @@ -18,10 +18,11 @@ test("GitHubProvider constructor validates owner/repo format", () => { assert.throws(() => new GitHubProvider({ token: "t", repo: "a/b/c" }), /owner\/repo/i); }); -test("GitHubProvider.fetch: empty repo returns empty changeset", async () => { +test("GitHubProvider.fetch: fetch returns empty changeset when repo exists but main branch does not", async () => { const api = new MockGitHubAPI(); const cleanup = api .on("GET", "/repos/user/repo/branches/main", { status: 404, body: { message: "Not Found" } }) + .on("GET", "/repos/user/repo", { status: 200, body: { full_name: "user/repo" } }) .install(); try { @@ -36,6 +37,38 @@ test("GitHubProvider.fetch: empty repo returns empty changeset", async () => { } }); +test("GitHubProvider.fetch: throws Repository not found when the repo probe returns 404", async () => { + const api = new MockGitHubAPI(); + const cleanup = api + .on("GET", "/repos/user/repo/branches/main", { status: 404, body: { message: "Not Found" } }) + .on("GET", "/repos/user/repo", { status: 404, body: { message: "Not Found" } }) + .install(); + + try { + const provider = makeProvider(); + await assert.rejects(() => provider.fetch({}), /Repository not found: user\/repo/); + api.assertDone(); + } finally { + cleanup(); + } +}); + +test("GitHubProvider.fetch: throws auth error when the repo probe returns 401", async () => { + const api = new MockGitHubAPI(); + const cleanup = api + .on("GET", "/repos/user/repo/branches/main", { status: 404, body: { message: "Not Found" } }) + .on("GET", "/repos/user/repo", { status: 401, body: { message: "Bad credentials" } }) + .install(); + + try { + const provider = makeProvider(); + await assert.rejects(() => provider.fetch({}), /GitHub authentication failed/); + api.assertDone(); + } finally { + cleanup(); + } +}); + test("GitHubProvider.fetch: diffs remote snapshot and fetches changed text", async () => { const remoteSnapshot = { "hello.md": { hash: "sha256-new" }, diff --git a/tests/unit/global-config.test.ts b/tests/unit/global-config.test.ts new file mode 100644 index 0000000..896fe29 --- /dev/null +++ b/tests/unit/global-config.test.ts @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getGlobalConfigPath, getStashPath } from "../../src/global-config.ts"; + +test("stash paths prefer XDG_CONFIG_HOME when set", () => { + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = "/tmp/stash-xdg"; + + try { + assert.equal(getStashPath(), "/tmp/stash-xdg/stash"); + assert.equal(getGlobalConfigPath(), "/tmp/stash-xdg/stash/config.json"); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + } +}); diff --git a/tests/unit/local-config.test.ts b/tests/unit/local-config.test.ts deleted file mode 100644 index 5af8019..0000000 --- a/tests/unit/local-config.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { normalizeLocalConfig } from "../../src/local-config.ts"; - -test("normalizeLocalConfig skips connections missing provider", () => { - assert.deepEqual( - normalizeLocalConfig({ - connections: { - origin: { repo: "user/repo" }, - backup: { provider: "github", repo: "user/repo" }, - }, - }), - { - connections: { - backup: { provider: "github", repo: "user/repo" }, - }, - }, - ); -}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts deleted file mode 100644 index a68a264..0000000 --- a/tests/unit/migrations.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { MigrationError } from "../../src/errors.ts"; -import { ensureMigration } from "../../src/migrations.ts"; -import { Stash } from "../../src/stash.ts"; -import type { ChangeSet, Provider, ProviderClass } from "../../src/types.ts"; -import { makeTempDir, writeFiles } from "../helpers/make-stash.ts"; - -test("ensureMigration: moves config.local.json to config.json", async () => { - const dir = await makeTempDir("migrate-config"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify({ connections: { github: { repo: "user/repo" } } }, null, 2), - "utf8", - ); - - await ensureMigration(dir); - - assert.equal(existsSync(join(dir, ".stash", "config.local.json")), false); - assert.deepEqual(JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")), { - connections: { - github: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: moves snapshot.local to snapshot", async () => { - const dir = await makeTempDir("migrate-snapshot"); - try { - await mkdir(join(dir, ".stash", "snapshot.local"), { recursive: true }); - await writeFile(join(dir, ".stash", "snapshot.local", "hello.md"), "hello", "utf8"); - - await ensureMigration(dir); - - assert.equal(existsSync(join(dir, ".stash", "snapshot.local")), false); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "hello.md"), "utf8"), "hello"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: no-ops when the new layout already exists", async () => { - const dir = await makeTempDir("migrate-noop"); - try { - await mkdir(join(dir, ".stash", "snapshot"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify({ connections: {} }, null, 2), - "utf8", - ); - - await ensureMigration(dir); - - assert.equal(existsSync(join(dir, ".stash", "config.json")), true); - assert.equal(existsSync(join(dir, ".stash", "snapshot")), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: backfills provider when config.json connection is missing it", async () => { - const dir = await makeTempDir("migrate-provider"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify({ connections: { github: { repo: "user/repo" } } }, null, 2), - "utf8", - ); - - await ensureMigration(dir); - - assert.deepEqual(JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")), { - connections: { - github: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: does not rewrite connections that already have a provider", async () => { - const dir = await makeTempDir("migrate-provider-noop"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { connections: { origin: { provider: "github", repo: "user/repo" } } }, - null, - 2, - ), - "utf8", - ); - - await ensureMigration(dir); - - assert.deepEqual(JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")), { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: throws when both old and new config paths exist", async () => { - const dir = await makeTempDir("migrate-config-conflict"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile(join(dir, ".stash", "config.local.json"), "{}", "utf8"); - await writeFile(join(dir, ".stash", "config.json"), "{}", "utf8"); - - await assert.rejects(ensureMigration(dir), MigrationError); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: throws when both old and new snapshot paths exist", async () => { - const dir = await makeTempDir("migrate-snapshot-conflict"); - try { - await mkdir(join(dir, ".stash", "snapshot.local"), { recursive: true }); - await mkdir(join(dir, ".stash", "snapshot"), { recursive: true }); - - await assert.rejects(ensureMigration(dir), MigrationError); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("Stash.load migrates old config.local.json", async () => { - const dir = await makeTempDir("migrate-load"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify({ connections: { github: { repo: "user/repo" } } }, null, 2), - "utf8", - ); - - const stash = await Stash.load(dir, { providers: {}, background: { stashes: [] } }); - - assert.deepEqual(stash.connections, { - github: { provider: "github", repo: "user/repo" }, - }); - assert.equal(existsSync(join(dir, ".stash", "config.local.json")), false); - assert.equal(existsSync(join(dir, ".stash", "config.json")), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("Stash.load preserves old snapshot.local merge bases through migration", async () => { - const dir = await makeTempDir("migrate-merge-base"); - const baseline: string = "hello world"; - const remote: string = "hello cruel world"; - const local: string = "hello brave world"; - - class TestProvider implements Provider { - static spec = { setup: [], connect: [{ name: "repo", label: "Repo" }] }; - async fetch(): Promise { - return { - added: new Map(), - modified: new Map([["note.md", { type: "text" as const, content: remote }]]), - deleted: [], - }; - } - async get(): Promise { - throw new Error("not needed"); - } - async push(): Promise {} - } - - try { - await writeFiles(dir, { "note.md": local }); - await mkdir(join(dir, ".stash", "snapshot.local"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify({ connections: { fake: { repo: "r" } } }, null, 2), - "utf8", - ); - await writeFile( - join(dir, ".stash", "snapshot.json"), - JSON.stringify({ "note.md": { hash: "sha256-old" } }, null, 2), - "utf8", - ); - await writeFile(join(dir, ".stash", "snapshot.local", "note.md"), baseline, "utf8"); - - const stash = await Stash.load( - dir, - { providers: {}, background: { stashes: [] } }, - { providers: { fake: TestProvider as unknown as ProviderClass } }, - ); - await stash.sync(); - - const merged = await readFile(join(dir, "note.md"), "utf8"); - assert.equal(merged.includes("brave"), true); - assert.equal(merged.includes("cruel"), true); - assert.equal(existsSync(join(dir, ".stash", "snapshot.local")), false); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "note.md")), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/unit/parse-uri.test.ts b/tests/unit/parse-uri.test.ts new file mode 100644 index 0000000..423d3a3 --- /dev/null +++ b/tests/unit/parse-uri.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { InvalidUriError } from "../../src/errors.ts"; +import { parseUri } from "../../src/uri.ts"; + +test("parseUri splits on the first colon", () => { + assert.deepEqual(parseUri("github:owner/repo"), { + provider: "github", + config: { id: "owner/repo" }, + }); +}); + +test("parseUri preserves additional colons in the id", () => { + assert.deepEqual(parseUri("s3:bucket/prefix:with:colon"), { + provider: "s3", + config: { id: "bucket/prefix:with:colon" }, + }); +}); + +test("parseUri throws when the uri is missing a colon", () => { + assert.throws(() => parseUri("github"), InvalidUriError); +}); + +test("parseUri throws when the provider is empty", () => { + assert.throws(() => parseUri(":bucket"), /missing provider/i); +}); + +test("parseUri throws when the id is empty", () => { + assert.throws(() => parseUri("github:"), /missing id/i); +}); diff --git a/tests/unit/registry-lock.test.ts b/tests/unit/registry-lock.test.ts new file mode 100644 index 0000000..ec031bf --- /dev/null +++ b/tests/unit/registry-lock.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getRegistryLockPath, withRegistryLock } from "../../src/registry-lock.ts"; + +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +test("withRegistryLock creates a 0600 lock file and releases it after the callback", async () => { + const xdg = await makeTempDir("stash-lock"); + const previous = process.env.XDG_CONFIG_HOME; + + process.env.XDG_CONFIG_HOME = xdg; + + try { + const seenPayloads: string[] = []; + + await withRegistryLock(async () => { + const lockPath = getRegistryLockPath(); + assert.equal(existsSync(lockPath), true); + assert.equal((await stat(lockPath)).mode & 0o777, 0o600); + seenPayloads.push(await readFile(lockPath, "utf8")); + }); + + assert.equal(seenPayloads.length, 1); + assert.equal(existsSync(getRegistryLockPath()), false); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + await rm(xdg, { recursive: true, force: true }); + } +}); + +test("withRegistryLock serializes concurrent callers", async () => { + const xdg = await makeTempDir("stash-lock-serial"); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdg; + + try { + const events: string[] = []; + let releaseFirst: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withRegistryLock(async () => { + events.push("first:start"); + await gate; + events.push("first:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + const second = withRegistryLock(async () => { + events.push("second:start"); + events.push("second:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.deepEqual(events, ["first:start"]); + + releaseFirst?.(); + await Promise.all([first, second]); + + assert.deepEqual(events, ["first:start", "first:end", "second:start", "second:end"]); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + await rm(xdg, { recursive: true, force: true }); + } +}); + +test("withRegistryLock reclaims a stale lock left by a dead process", async () => { + const xdg = await makeTempDir("stash-lock-stale"); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdg; + + try { + const lockPath = getRegistryLockPath(); + await mkdir(join(xdg, "stash"), { recursive: true }); + await writeFile( + lockPath, + JSON.stringify( + { + pid: 999_999, + startedAt: new Date(Date.now() - 5 * 60_000).toISOString(), + hostname: "test-host", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + let acquired = false; + await withRegistryLock(async () => { + acquired = true; + assert.equal(existsSync(lockPath), true); + }); + + assert.equal(acquired, true); + assert.equal(existsSync(lockPath), false); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + await rm(xdg, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/stash-config.test.ts b/tests/unit/stash-config.test.ts index d3de11d..9d69dbd 100644 --- a/tests/unit/stash-config.test.ts +++ b/tests/unit/stash-config.test.ts @@ -6,24 +6,25 @@ import type { Provider } from "../../src/types.ts"; import { makeStash } from "../helpers/make-stash.ts"; test("connect writes named provider connection config", async () => { - const { stash, dir } = await makeStash(); + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { - origin: { provider: "github", repo: "user/repo" }, + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, [ + { + provider: "github", + config: { name: "origin", repo: "user/repo" }, }, - }); + ]); }); test("disconnect removes named connection config", async () => { - const { stash, dir } = await makeStash(); + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); await stash.disconnect("origin"); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { connections: {} }); + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, []); }); test("connections getter reflects latest local state", async () => { diff --git a/tests/unit/stash-scan.test.ts b/tests/unit/stash-scan.test.ts index e31d197..38e8b5c 100644 --- a/tests/unit/stash-scan.test.ts +++ b/tests/unit/stash-scan.test.ts @@ -59,6 +59,7 @@ test("scan: new file on disk is added when absent in snapshot", async () => { test("scan: dotfiles, symlinks, and .stash are ignored", async () => { const { stash, dir } = await makeStash({ "visible.md": "public" }); await writeFiles(dir, { ".hidden": "secret", ".config/settings.json": "{}" }); + await mkdir(join(dir, ".stash"), { recursive: true }); await writeFile(join(dir, ".stash", "noise.txt"), "ignored", "utf8"); await mkdir(join(dir, "links"), { recursive: true }); await symlink(join(dir, "visible.md"), join(dir, "links", "visible-link.md")); diff --git a/tests/unit/stash-sync-lock.test.ts b/tests/unit/stash-sync-lock.test.ts index c4a83c3..f91d8d3 100644 --- a/tests/unit/stash-sync-lock.test.ts +++ b/tests/unit/stash-sync-lock.test.ts @@ -18,8 +18,8 @@ function fakeRegistry(fake: FakeProvider) { return { fake: TestProvider as any }; } -function lockPath(dir: string): string { - return join(dir, ".stash", "sync.lock"); +function lockPath(metadataDir: string): string { + return join(metadataDir, "sync.lock"); } function lockPayload(startedAt: string): string { @@ -36,19 +36,19 @@ function lockPayload(startedAt: string): string { test("sync lock: lock acquired and released on successful sync", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); test("sync lock: lock released when sync fails", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -59,41 +59,41 @@ test("sync lock: lock released when sync fails", async () => { }; await assert.rejects(stash.sync(), /push failed/); - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); test("sync lock: SyncLockError when lock is held by another process", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash({}, { providers: fakeRegistry(fake) }); + const { stash, metadataDir } = await makeStash({}, { providers: fakeRegistry(fake) }); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - await writeFile(lockPath(dir), lockPayload(new Date().toISOString()), "utf8"); + await writeFile(lockPath(metadataDir), lockPayload(new Date().toISOString()), "utf8"); await assert.rejects(stash.sync(), SyncLockError); }); test("sync lock: stale lock is reclaimed", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); const stale = new Date(Date.now() - 15 * 60 * 1_000).toISOString(); - await writeFile(lockPath(dir), lockPayload(stale), "utf8"); + await writeFile(lockPath(metadataDir), lockPayload(stale), "utf8"); await stash.sync(); - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); test("sync lock: stale reclaim race throws SyncLockError", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - const lock = lockPath(dir); + const lock = lockPath(metadataDir); const stale = new Date(Date.now() - 15 * 60 * 1_000).toISOString(); await writeFile(lock, lockPayload(stale), "utf8"); @@ -113,7 +113,7 @@ test("sync lock: stale reclaim race throws SyncLockError", async () => { test("sync lock: in-process single flight throws SyncLockError", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -128,5 +128,5 @@ test("sync lock: in-process single flight throws SyncLockError", async () => { const firstSync = stash.sync(); await assert.rejects(stash.sync(), SyncLockError); await firstSync; - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts index f4bc287..2a2aa27 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -1,13 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; +import { join } from "node:path"; import { Emitter } from "../../src/emitter.ts"; -import { watch, Watch, type WatchStatus } from "../../src/watch.ts"; -import { makeStash } from "../helpers/make-stash.ts"; -import { FakeProvider } from "../helpers/fake-provider.ts"; +import { Watch, type WatchStatus } from "../../src/watch.ts"; import type { FileMutation } from "../../src/types.ts"; class FakeStash extends Emitter<{ mutation: FileMutation }> { @@ -21,32 +17,6 @@ class FakeStash extends Emitter<{ mutation: FileMutation }> { } } -class FakeInput extends EventEmitter { - isTTY = true; - resume(): void {} - setRawMode(): void {} -} - -class FakeOutput { - isTTY = true; - output = ""; - write(chunk: string): boolean { - this.output += chunk; - return true; - } -} - -function fakeRegistry(fake: FakeProvider) { - class TestProvider { - static spec = { setup: [], connect: [{ name: "repo", label: "Repo" }] }; - constructor() { - return fake; - } - } - - return { fake: TestProvider as any }; -} - test("watch: performs an initial check even when no providers are connected", async () => { const stash = new FakeStash(); const statuses: WatchStatus[] = []; @@ -183,33 +153,3 @@ test("watch: falls back to poll-only when subscribe throws", async () => { await watch.stop(); }); - -test("watch: git safety error is rendered with retry guidance", async () => { - const fake = new FakeProvider(); - const { stash, dir } = await makeStash( - { "hello.md": "hello" }, - { providers: fakeRegistry(fake) }, - ); - await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - const stdin = new FakeInput(); - const stdout = new FakeOutput(); - - await mkdir(join(dir, ".git"), { recursive: true }); - - const watchPromise = watch(stash, { - dir, - stdin: stdin as unknown as NodeJS.ReadStream, - stdout: stdout as unknown as NodeJS.WriteStream, - pollMs: 50, - subscribe: async () => ({ - unsubscribe: async () => {}, - }), - }); - - await delay(60); - stdin.emit("data", Buffer.from("q")); - await watchPromise; - - assert.equal(stdout.output.includes("git repository detected"), true); - assert.equal(stdout.output.includes("retrying in"), true); -});