diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17ba379..8a3c788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,35 +8,30 @@ on: jobs: cli-tests: - name: CLI Tests (PHP 8.2) + name: CLI Tests (PHP ${{ matrix.php }}) runs-on: ubuntu-latest + strategy: + matrix: + php: ["8.2", "8.3"] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: "8.2" + php-version: ${{ matrix.php }} extensions: pdo_sqlite, openssl, zip, mbstring, json - coverage: xdebug + coverage: none - name: Install CLI dependencies - working-directory: contextbridge + working-directory: movez run: composer install --no-interaction --prefer-dist --optimize-autoloader - name: Run CLI tests - working-directory: contextbridge + working-directory: movez run: php vendor/bin/pest env: - CB_KEY_PATH: /tmp/cb_test_key - CB_TOKEN_PATH: /tmp/cb_test_token - - - name: Upload coverage - uses: codecov/codecov-action@v4 - with: - files: movez/coverage.xml - flags: cli - continue-on-error: true - if: false + MOVEZ_KEY_PATH: /tmp/movez_test_key + MOVEZ_TOKEN_PATH: /tmp/movez_test_token web-tests: name: Web Tests (PHP 8.2 + Node 20) @@ -48,7 +43,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: "8.2" - extensions: pdo_sqlite, mbstring, openssl, zip, bcmath + extensions: pdo_sqlite, mbstring, openssl, zip, bcmath, pcntl, posix - uses: actions/setup-node@v4 with: @@ -64,6 +59,10 @@ jobs: working-directory: web run: npm ci + - name: Type check frontend + working-directory: web + run: npm run type-check + - name: Build frontend assets working-directory: web run: npm run build @@ -99,11 +98,36 @@ jobs: working-directory: vscode-extension run: npm ci - - name: TypeScript type check + - name: Type check and compile working-directory: vscode-extension - run: npx tsc --noEmit + run: npm test - - name: Run extension tests + - name: Package VSIX (non-interactive) working-directory: vscode-extension - run: npm test - continue-on-error: true + run: npx vsce package --no-dependencies --out movez-vscode.vsix + + desktop-typecheck: + name: Desktop App Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: electron-app/package-lock.json + + - name: Install desktop dependencies + working-directory: electron-app + run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + + - name: Type check + working-directory: electron-app + run: npm run typecheck + + - name: Build + working-directory: electron-app + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 454e719..8317e24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,29 +19,26 @@ jobs: with: php-version: "8.2" extensions: pdo_sqlite, openssl, zip, mbstring + ini-values: phar.readonly=0 - name: Install CLI dependencies (no dev) - working-directory: contextbridge + working-directory: movez run: composer install --no-dev --optimize-autoloader --no-interaction - name: Build PHAR - working-directory: contextbridge - run: php movez app:build movez --build-version=${{ github.ref_name }} --no-interaction + working-directory: movez + run: php movez app:build movez.phar --build-version=${{ github.ref_name }} --no-interaction - - name: Upload PHAR to release - uses: softprops/action-gh-release@v2 - with: - files: contextbridge/builds/movez - name: MoveZ ${{ github.ref_name }} - body: | - ## MoveZ ${{ github.ref_name }} + - name: Smoke test PHAR + working-directory: movez + run: | + php builds/movez.phar --version + php builds/movez.phar list --raw | grep -q '^export' - ### Installation - ```bash - chmod +x movez - mv movez /usr/local/bin/movez - movez doctor - ``` + - uses: actions/upload-artifact@v4 + with: + name: movez-phar + path: movez/builds/movez.phar build-vsix: name: Build VS Code Extension @@ -52,6 +49,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" + cache: npm + cache-dependency-path: vscode-extension/package-lock.json - name: Install extension dependencies working-directory: vscode-extension @@ -59,9 +58,93 @@ jobs: - name: Package extension working-directory: vscode-extension - run: npx vsce package --no-dependencies + run: npx vsce package --no-dependencies --out movez-vscode.vsix + + - uses: actions/upload-artifact@v4 + with: + name: movez-vsix + path: vscode-extension/movez-vscode.vsix + + build-desktop: + name: Build Windows Desktop Installer + needs: build-phar + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: electron-app/package-lock.json + + - uses: actions/download-artifact@v4 + with: + name: movez-phar + path: electron-app/resources - - name: Upload VSIX to release - uses: softprops/action-gh-release@v2 + - name: Bundle PHP runtime + shell: pwsh + working-directory: electron-app/resources + run: | + Invoke-WebRequest -Uri "https://windows.php.net/downloads/releases/latest/php-8.3-nts-Win32-vs16-x64-latest.zip" -OutFile php.zip + Expand-Archive php.zip -DestinationPath php + Remove-Item php.zip + @( + 'extension_dir = "ext"', + 'extension=openssl', + 'extension=pdo_sqlite', + 'extension=zip', + 'extension=mbstring', + 'extension=fileinfo' + ) | Set-Content -Encoding ascii php/php.ini + php/php.exe -v + php/php.exe movez.phar --version + + - name: Install desktop dependencies + working-directory: electron-app + run: npm ci + + - name: Set version from tag + working-directory: electron-app + shell: pwsh + run: npm version "$("${{ github.ref_name }}".TrimStart('v'))" --no-git-tag-version --allow-same-version + + - name: Build installer + working-directory: electron-app + run: npm run package -- --win --publish never + + - uses: actions/upload-artifact@v4 with: - files: vscode-extension/*.vsix + name: movez-desktop + path: electron-app/dist/*.exe + + publish: + name: Publish GitHub Release + needs: [build-phar, build-vsix, build-desktop] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - uses: softprops/action-gh-release@v2 + with: + name: MoveZ ${{ github.ref_name }} + files: artifacts/* + body: | + ## MoveZ ${{ github.ref_name }} + + | File | What it is | + |---|---| + | `MoveZ-Setup-*.exe` | Windows desktop app (bundles PHP and the CLI) | + | `movez.phar` | Cross-platform CLI (needs PHP 8.2+ with pdo_sqlite, openssl, zip, mbstring) | + | `movez-vscode.vsix` | VS Code / Cursor extension — install with "Extensions: Install from VSIX…" | + + ### CLI quick start + ```bash + curl -L https://github.com/kv4u/MoveZ/releases/latest/download/movez.phar -o movez + chmod +x movez && sudo mv movez /usr/local/bin/movez + movez doctor + ``` diff --git a/.gitignore b/.gitignore index 5539c9e..a1d55b3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,13 @@ vendor/ # Build artifacts electron-app/dist/ electron-app/out/ -contextbridge/contextbridge.phar -contextbridge/box.phar -contextbridge/builds/ +movez/movez.phar +movez/box.phar +movez/builds/ vscode-extension/*.vsix +vscode-extension/out/ +web/public/build/ +*.tsbuildinfo # Bundled runtime binaries (downloaded during setup, not source) electron-app/resources/php/ @@ -45,5 +48,5 @@ npm-debug.log* .php-cs-fixer.cache # Personal utility scripts -contextbridge/bundle_export.php -contextbridge/make_cbz.php +movez/bundle_export.php +movez/make_cbz.php diff --git a/AGENTS.md b/AGENTS.md index 8213c53..c7c7dad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ movez/ ← Laravel Zero CLI │ ├── Support/ BundleSchema, PlatformPaths │ └── Commands/ ExportCommand, ImportCommand, TransferCommand, PackageCommand, │ UnpackCommand, Sync/SyncPushCommand, Sync/SyncPullCommand, -│ ListCommand, DoctorCommand +│ ListCommand, ShowCommand, DoctorCommand ├── config/movez.php └── tests/ Unit/ + Feature/ (Pest PHP 3) @@ -99,6 +99,10 @@ web/ ← Laravel 12 Dashboard + Sync Server │ TurnBlock.vue, SessionCard.vue └── e2e/ Playwright tests +electron-app/ ← Electron + Vue 3 Windows desktop app (bundles PHP + movez.phar) +├── electron/ main.ts, preload.ts, cli.ts, ipc/*.ts +└── src/ pages/, components/, stores/ + vscode-extension/ ← TypeScript VS Code extension ├── src/ extension.ts, SessionTreeProvider.ts, MigrationPanel.ts └── package.json @@ -143,8 +147,8 @@ Key stored at `~/.movez/key` with chmod 0600. ## 6. Bundle Format (.cbz) ZIP archive containing: -- `bundle.json` — array of SessionDTO serialized via `toArray()` -- `manifest.json` — `{ version, source_tool, machine_sha, exported_at, session_count }` +- `bundle.json` — `{ version, source_tool, exported_at, sessions: SessionDTO::toArray()[] }` (encrypted string when `manifest.encrypted`) +- `manifest.json` — `{ version, source_tool, machine_sha, exported_at, session_count, encrypted }` - `config.json` (optional) — ProjectConfigDTO Required keys validated by `BundleSchema::validate()`: `version`, `sessions`, `source_tool`, `exported_at` @@ -155,25 +159,28 @@ Required keys validated by `BundleSchema::validate()`: `version`, `sessions`, `s | Tool | Format | Storage | |---|---|---| -| cursor | SQLite state.vscdb | workspaceStorage/ | -| windsurf | SQLite state.vscdb | workspaceStorage/ | +| cursor | JSONL transcripts + SQLite state.vscdb | ~/.cursor/projects/ + globalStorage/workspaceStorage | +| windsurf | Encrypted protobuf (Cascade) | not supported — parser/writer throw | | claude-code | JSONL | ~/.claude/projects/ | -| codex | JSONL | ~/.codex/sessions/ | +| codex | JSONL rollout (session_meta/response_item) | ~/.codex/sessions/YYYY/MM/DD/ | | copilot-cli | JSON | ~/.copilot/sessions/ | -| cline | JSON (glob) | ~/.vscode/extensions/saoudrizwan.claude-dev-*/ | -| continue | SQLite sessions.db | ~/.continue/ | +| cline | JSON per task (read-only) | ~/.vscode/extensions/saoudrizwan.claude-dev-*/data/tasks// | +| continue | SQLite sessions.db (read-only) | ~/.continue/ | --- ## 8. API Routes (Sync Server) ``` -POST /api/sync/push — Bearer token, body: { sessions: encrypted_json } -GET /api/sync/pull — Bearer token, returns: { sessions: encrypted_json } +POST /api/sync/push — Bearer token, body: { sessions: encrypted_json, count?: int } +GET /api/sync/pull — Bearer token, returns: { sessions: encrypted_json|null, count: int } ``` Auth: `ApiTokenMiddleware` — Bearer token → `hash('sha256', $token)` → match `users.api_token` +Payloads are stored in `sync_blobs` (one per user). Issue tokens with `php artisan movez:token `. +CLI clients read `MOVEZ_TOKEN` / `MOVEZ_SERVER_URL` so tokens never appear in process lists. + --- ## 9. VS Code Extension Commands @@ -184,5 +191,6 @@ Auth: `ApiTokenMiddleware` — Bearer token → `hash('sha256', $token)` → mat - `movez.syncPush` - `movez.syncPull` - `movez.refreshSessions` +- `movez.setToken` (stores the sync token in SecretStorage) -Settings: `movez.cliPath`, `movez.serverUrl`, `movez.token` +Settings: `movez.cliPath`, `movez.phpPath`, `movez.serverUrl` (`movez.token` is deprecated) diff --git a/README.md b/README.md index be12cbd..19b0352 100644 --- a/README.md +++ b/README.md @@ -1,311 +1,261 @@ -# MoveZ +

MoveZ

-**Unified AI coding session transfer — cross-tool AND cross-machine.** +

+ Move your AI coding sessions between tools and between machines.
+ Cursor · Claude Code · Codex · Copilot CLI · Cline · Continue +

-Transfer sessions between Cursor, Windsurf, Claude Code, Codex, Copilot CLI, Cline, and Continue. -Sync encrypted sessions across machines via a self-hosted server. +

+ CI + Latest release + MIT license + PHP 8.2+ +

--- -## Quick Start (Desktop App) +## Why MoveZ? -The easiest way to use MoveZ is through the desktop app — no terminal needed. +Your conversations with AI coding assistants hold real project context. Switching tools or laptops usually means losing it. -### 1. Install +| | Cross-tool | Cross-machine | Tools covered | +|---|:---:|:---:|---| +| cli-continues | ✅ | ❌ | 14 agents | +| cursor-chat-transfer | ❌ | ✅ | Cursor only | +| claude-conversation-extractor | ❌ | ✅ | Claude Code only | +| **MoveZ** | ✅ | ✅ | 6 tools (read), 4 tools (write) | -Download `MoveZ Setup 1.0.0.exe` from [Releases](https://github.com/kv4u/MoveZ/releases) and run the installer. +With MoveZ you can: -> The installer bundles PHP and the CLI automatically — no extra dependencies required. - -### 2. Open the App - -Launch **MoveZ** from the Start Menu. You'll see a sidebar with these sections: - -| Section | What it does | -|---|---| -| **Dashboard** | Overview of detected AI tools and session counts | -| **Sessions** | Browse all sessions from all your AI tools | -| **Migrate** | Transfer sessions from one tool to another | -| **Sync** | Push/pull encrypted sessions to a sync server | -| **Doctor** | Check that everything is working | -| **Settings** | Configure CLI path, PHP path, sync server | - -### 3. Export Sessions (Backup) - -1. Go to **Sessions** -2. Select a tool tab (e.g. Cursor, Claude Code) -3. Check the sessions you want to export -4. Click **Export Selected** → choose a save location -5. A `.cbz` bundle file is created — this is your portable backup - -### 4. Import Sessions (Restore) - -1. Go to **Sessions** -2. Click **Import** -3. Select your `.cbz` bundle file -4. Choose the target tool (e.g. Cursor, Windsurf) -5. Optionally remap project paths if your folder structure changed -6. Click **Import** — sessions appear in the target tool - -### 5. Transfer Between Tools - -1. Go to **Migrate** -2. Select **From** tool (e.g. Cursor) and **To** tool (e.g. Claude Code) -3. Set the project directory -4. Click **Start Migration** -5. Sessions are read from the source and written to the target tool - -### 6. Run Doctor (Troubleshooting) - -Click **Doctor** to verify: -- PHP is available and the right version -- All AI tool storage directories are detected -- SQLite extensions are loaded -- Encryption key exists +- **Back up** sessions to a portable `.cbz` bundle, optionally AES-256-GCM encrypted. +- **Transfer** sessions from one tool to another, e.g. Cursor → Claude Code. +- **Migrate** to a new machine, remapping project paths (`D:\Projects` → `C:\Work`). +- **Sync** through a self-hosted server. Sessions are encrypted before they leave your machine. --- -## Quick Start (CLI) +## Get MoveZ -For power users who prefer the terminal. +Everything is on the [latest release](https://github.com/kv4u/MoveZ/releases/latest): -### Install +| Download | For | Needs | +|---|---|---| +| `MoveZ-Setup-.exe` | Windows desktop app | Nothing, PHP and the CLI are bundled | +| `movez.phar` | CLI on macOS, Linux, Windows | PHP 8.2+ with `pdo_sqlite`, `openssl`, `zip`, `mbstring` | +| `movez-vscode.vsix` | VS Code / Cursor extension | The CLI (above) | + +### CLI install ```bash -# Download the PHAR curl -L https://github.com/kv4u/MoveZ/releases/latest/download/movez.phar -o movez chmod +x movez sudo mv movez /usr/local/bin/movez - -# Verify installation movez doctor ``` -On Windows (with PHP installed): -```powershell -# Download movez.phar to a folder in your PATH -php movez.phar doctor -``` +On Windows, run it through PHP: `php movez.phar doctor`. -### Common Workflows +`movez doctor` checks PHP and its extensions, your encryption key, and which AI tools it finds on this machine. -**Backup all Cursor sessions:** -```bash -movez export --tool=cursor --output=cursor-backup.cbz -``` +--- -**Restore to a new machine:** -```bash -movez import --input=cursor-backup.cbz --tool=cursor -``` +## Quick start -**Transfer Cursor sessions to Claude Code:** ```bash -movez transfer --from=cursor --to=claude-code --project=/path/to/project -``` +# See what's on this machine +movez list-sessions +movez list-sessions --tool=claude-code --project=. --json -**Remap paths when restoring on a different machine:** -```bash -movez import --input=backup.cbz --tool=cursor \ - --from-path=/old/machine/projects \ - --to-path=/new/machine/projects -``` +# Back up all Cursor sessions to a bundle +movez export --tool=cursor --output=cursor-backup.cbz -**Encrypted backup:** -```bash -movez export --tool=cursor --output=backup.cbz --encrypt -# Encryption key is auto-generated at ~/.movez/key -``` +# Back up only this project's Claude Code sessions, encrypted +movez export --tool=claude-code --project=. --output=claude.cbz --encrypt -**List sessions without exporting:** -```bash -movez list-sessions --tool=cursor -movez list-sessions --tool=claude-code --json -``` +# Restore a bundle into Claude Code on a new machine, fixing paths +movez import --input=cursor-backup.cbz --tool=claude-code \ + --project=/home/me/work/my-app \ + --from-path="D:/Projects" --to-path="/home/me/work" -**Sync across machines (requires self-hosted server):** -```bash -# On machine A — push -movez sync:push --token=YOUR_TOKEN --server=https://your-server.com +# Copy sessions from one tool to another in one step +movez transfer --from=cursor --to=claude-code --project=/path/to/project -# On machine B — pull -movez sync:pull --token=YOUR_TOKEN --server=https://your-server.com --tool=cursor +# Read one full session +movez show --tool=claude-code --id= ``` +`--project` limits a command to sessions whose project folder matches the given path. Leave it out to include every project. + --- -## All CLI Commands +## Commands -| Command | Description | -|---|---| -| `movez export` | Export sessions to a `.cbz` bundle | -| `movez import` | Import sessions from a `.cbz` bundle | -| `movez transfer` | Export + import in one step | -| `movez package` | Package raw session files into a `.cbz` | -| `movez unpack` | Extract a `.cbz` bundle | -| `movez list-sessions` | List detected sessions | -| `movez sync:push` | Push encrypted sessions to sync server | -| `movez sync:pull` | Pull sessions from sync server | -| `movez doctor` | Check system requirements | - -Use `--help` on any command for full options: -```bash -movez export --help -``` +| Command | What it does | Key options | +|---|---|---| +| `list-sessions` | List detected sessions | `--tool`, `--project`, `--json` | +| `show` | Print one session with all turns | `--tool`, `--id`, `--json` | +| `export` | Write sessions to a `.cbz` bundle (or `.json`) | `--tool` (default `auto`), `--output`, `--project`, `--encrypt` | +| `import` | Load a bundle into a tool | `--input`, `--tool`, `--project`, `--from-path`, `--to-path` | +| `transfer` | Export + import in one step | `--from`, `--to`, `--project`, `--from-path`, `--to-path` | +| `package` | Turn a `.json` session list into a `.cbz` | `--input`, `--output`, `--encrypt` | +| `unpack` | Extract a `.cbz` into one JSON file per session | `--input`, `--output` | +| `sync:push` | Encrypt and upload sessions to your server | `--server`, `--tool`, `--project` | +| `sync:pull` | Download, decrypt and import sessions | `--server`, `--tool`, `--project`, `--from-path`, `--to-path` | +| `doctor` | Check requirements and detected tools | | + +Run `movez --help` for details. --- -## Supported Tools +## Supported tools -| Tool | Read | Write | Format | -|---|---|---|---| -| Cursor | Yes | Yes | SQLite + JSONL (3-layer) | -| Windsurf | Yes | Yes | SQLite | -| Claude Code | Yes | Yes | JSONL | -| Codex | Yes | Yes | JSONL | -| Copilot CLI | Yes | Yes | JSON | -| Cline | Yes | — | JSON | -| Continue | Yes | — | SQLite | +| Tool | Read | Write | Storage | +|---|:---:|:---:|---| +| Cursor | ✅ | ✅ | `~/.cursor/projects` transcripts + `state.vscdb` | +| Claude Code | ✅ | ✅ | `~/.claude/projects/*.jsonl` (+ Claude desktop app registry) | +| Codex CLI | ✅ | ✅ | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | +| Copilot CLI | ✅ | ✅ | `~/.copilot/sessions/*.json` | +| Cline | ✅ | — | VS Code extension `tasks//api_conversation_history.json` | +| Continue | ✅ | — | `~/.continue/sessions.db` | +| Windsurf | — | — | Not supported yet: Cascade stores conversations in an encrypted format | ---- +### Importing into Cursor -## Importing into Cursor (Important!) +Cursor only shows sessions in its sidebar for workspaces it already knows about. -Cursor uses a 3-layer storage system. MoveZ writes to all three so imported sessions appear correctly in the sidebar with full conversation history. +1. Open each target project in Cursor once, then **quit Cursor completely**. +2. Run `movez import --input=backup.cbz --tool=cursor --project=/path/to/project`. +3. Reopen Cursor. The imported chats appear in the history sidebar. -### Before You Import +MoveZ writes the transcript, the global `state.vscdb` entries and the workspace registration. The full conversation is visible, but Cursor's internal model context (`conversationState`) can't be recreated. To continue a conversation, start a new chat and reference the old one. -**You must open each project in Cursor at least once before importing.** Cursor creates a workspace storage directory the first time you open a project — MoveZ needs this directory to register sessions in the sidebar. +### Importing into Claude Code -### Step-by-Step +Sessions are written under `~/.claude/projects//`. On Windows they are also registered with the Claude desktop app, so they appear in its history. Re-importing a session updates it rather than duplicating it. -1. **Close Cursor** completely (File → Exit, not just close window) -2. **Open each project folder** you want to import sessions for in Cursor — just open it briefly, then close Cursor again. This creates the workspace storage entry. -3. **Run the import:** +--- + +## Sync between machines + +1. **Run a sync server.** See [docs/sync-server-setup.md](docs/sync-server-setup.md). Then issue yourself a token: + ```bash + php artisan movez:token you@example.com + ``` +2. **Configure each machine.** Prefer environment variables, because command-line arguments are visible to other processes: + ```bash + export MOVEZ_SERVER_URL=https://sync.example.com + export MOVEZ_TOKEN= # or save it in ~/.movez/token + ``` +3. **Share the encryption key.** Sessions are encrypted with `~/.movez/key`, which is created on your first encrypted export or push. **Copy that file to every machine you pull on.** Without it, nothing can be decrypted, including by the server. +4. **Push and pull:** ```bash - movez import --input=backup.cbz --tool=cursor + movez sync:push --tool=claude-code # machine A + movez sync:pull --tool=claude-code --project=~/work/app \ + --from-path="C:/Users/me/work" --to-path="/home/me/work" # machine B ``` - Or use the desktop app: Sessions → Import → select your `.cbz` file → choose Cursor as target. -4. **Reopen Cursor** on any project — your imported sessions will appear in the chat history sidebar. -### What Gets Written +--- -| Layer | Location | Purpose | -|---|---|---| -| 1. JSONL transcript | `~/.cursor/projects//agent-transcripts//` | Raw conversation text | -| 2. Global SQLite | `%APPDATA%/Cursor/User/globalStorage/state.vscdb` | Session metadata + individual messages (composerData + bubbleId entries) | -| 3. Workspace SQLite | `%APPDATA%/Cursor/User/workspaceStorage//state.vscdb` | Sidebar registration (makes session clickable) | +## Desktop app (Windows) + +Install `MoveZ-Setup-.exe` and launch **MoveZ**: -### If Sessions Don't Appear +| Page | What it does | +|---|---| +| Dashboard | Detected tools and session counts | +| Sessions | Browse, read, export and import sessions | +| Migrate | Tool-to-tool transfer with path remapping | +| Sync | Push and pull through your sync server | +| Doctor | Environment checks | +| Settings | CLI/PHP paths and sync server | -- **Session not in sidebar?** → You didn't open the project in Cursor before importing. Open it once, close Cursor, re-run the import. -- **Session shows but content is empty?** → Close Cursor fully and reopen. Cursor caches session state in memory. -- **Sessions from a different machine?** → Use `--from-path` and `--to-path` to remap project paths: - ```bash - movez import --input=backup.cbz --tool=cursor \ - --from-path="D:/Projects" \ - --to-path="C:/Work/Projects" - ``` +## VS Code / Cursor extension -### Limitations +Install `movez-vscode.vsix` with **Extensions: Install from VSIX…**. It adds a Sessions view for the open project, plus Export, Import, Migration Wizard and Sync commands. The sync token is kept in your OS keychain. See [docs/extension-setup.md](docs/extension-setup.md). -- **AI won't remember context** — Cursor uses an internal protobuf blob (`conversationState`) to feed conversation history to the model. MoveZ can't generate this format, so the AI won't "remember" imported conversations. You get full visual history of all messages, but to continue the conversation with context, start a new chat and reference the history. -- **Workspace hash is opaque** — Cursor generates workspace directory hashes using an internal algorithm. MoveZ can only register sessions for projects that have been opened (and thus have an existing hash). There's no way to pre-create workspace directories. +## Web dashboard + +The sync server also serves a dashboard: projects, sessions, and a migration wizard that builds the exact `movez transfer` command for you. It is meant for a private, single-user deployment; the dashboard has no login yet (see Roadmap). --- -## Bundle Format (.cbz) +## Bundle format (`.cbz`) -MoveZ uses `.cbz` (Compressed Bundle Zip) files as a portable bundle format: +A `.cbz` is a ZIP archive: -``` -bundle.cbz (ZIP archive) -└── bundle.json - ├── version: 1 - ├── source_tool: "cursor" - ├── exported_at: "2026-03-26T..." - └── sessions: [ - { id, title, project, turns: [{role, content, timestamp}...] } - ] -``` +| File | Contents | +|---|---| +| `bundle.json` | `{ version, source_tool, exported_at, sessions: [...] }`, AES-256-GCM encrypted when `manifest.encrypted` is true | +| `manifest.json` | `{ version, source_tool, machine_sha, exported_at, session_count, encrypted }` | +| `config.json` | Optional project config (rules, `CLAUDE.md`, MCP config) | -Bundles can optionally be encrypted with AES-256-GCM. +Each session has `id, title, project, source_tool, created_at, last_active_at, turn_count, turns[]`. Each turn has `role, content, timestamp, files_referenced, file_diffs, reasoning_trace, tool_calls`. + +Encrypted data uses the layout `base64(iv[12] ‖ tag[16] ‖ ciphertext)`. --- -## Architecture +## Repository layout ``` -contextbridge/ ← Laravel Zero CLI (builds to movez.phar) -electron-app/ ← Electron + Vue 3 desktop app -vscode-extension/ ← VS Code extension (TypeScript) -web/ ← Laravel 12 + Inertia.js web dashboard -.github/ ← GitHub Actions CI/CD +movez/ Laravel Zero CLI → movez.phar +web/ Laravel 12 + Inertia + Vue 3 dashboard and sync server +electron-app/ Electron + Vue 3 Windows desktop app (bundles PHP + movez.phar) +vscode-extension/ VS Code / Cursor extension (TypeScript) +docs/ Sync server and extension guides ``` -See [AGENTS.md](AGENTS.md) for the full engineering specification. +See [AGENTS.md](AGENTS.md) for the engineering spec and coding rules. ---- +## Building from source -## Building from Source +Requirements: PHP 8.2+ (`pdo_sqlite`, `openssl`, `zip`, `mbstring`), Composer 2, Node 20+. -### Requirements +```bash +# CLI +cd movez && composer install +php movez doctor +php movez app:build movez.phar # → movez/builds/movez.phar -- PHP 8.2+ with extensions: pdo_sqlite, openssl, zip, mbstring -- Composer 2.x -- Node 20+ +# Web +cd web && composer install && npm ci && npm run build +cp .env.example .env && php artisan key:generate && php artisan migrate -### Build the Desktop App +# Extension +cd vscode-extension && npm ci && npx vsce package --no-dependencies -```bash -# 1. Install dependencies -cd contextbridge && composer install && cd .. -cd electron-app && npm install && cd .. - -# 2. Download PHP runtime (Windows) -# Place php.exe + DLLs in electron-app/resources/php/ - -# 3. Build PHAR -cd contextbridge -php -d phar.readonly=0 box.phar compile -cp contextbridge.phar ../electron-app/resources/movez.phar -cd .. - -# 4. Build Electron installer -cd electron-app -npm run package -# Output: electron-app/dist/MoveZ Setup 1.0.0.exe +# Desktop app (Windows) +build.bat # needs movez/box.phar and PHP in electron-app/resources/php/ ``` -Or use the included build script (Windows): -```batch -build.bat -``` +Pushing a `v*` tag runs the release workflow, which builds and publishes the PHAR, the VSIX and the Windows installer. -### Run Tests +### Tests ```bash -# CLI tests -cd contextbridge && php vendor/bin/pest - -# Web tests -cd web && php artisan test - -# TypeScript check -cd vscode-extension && npx tsc --noEmit +cd movez && php vendor/bin/pest # CLI +cd web && php artisan test # web +cd web && npm run type-check # web frontend types +cd vscode-extension && npm test # extension type check +cd electron-app && npm run typecheck # desktop app type check ``` --- ## Security -- All synced data is encrypted with AES-256-GCM before leaving your machine -- Encryption key stored at `~/.movez/key` with 0600 permissions -- API tokens stored as SHA-256 hashes in the database -- No telemetry, no external calls except to your configured sync server +- Sessions are encrypted client-side with AES-256-GCM before export or sync. The server stores only ciphertext. +- The key lives at `~/.movez/key` (0600 on macOS/Linux) and never leaves your machines unless you copy it. +- API tokens are stored server-side as SHA-256 hashes. +- No telemetry. MoveZ only talks to the sync server you configure. ---- +## Roadmap + +- Windsurf support (blocked on its encrypted Cascade storage) +- Login and per-user scoping for the web dashboard +- Server-side migration jobs (Horizon) +- macOS and Linux desktop builds ## License -MIT — see [LICENSE](LICENSE) +MIT, see [LICENSE](LICENSE). diff --git a/build.bat b/build.bat index c4d467d..72edc04 100644 --- a/build.bat +++ b/build.bat @@ -18,6 +18,11 @@ set ELECTRON_APP=%~dp0electron-app :: ----------------------------------------------- echo [1/3] Building movez.phar ... cd /d "%PHAR_SRC%" +if not exist "%PHAR_SRC%\box.phar" ( + echo [ERROR] movez\box.phar not found. Download it from https://github.com/box-project/box/releases + pause + exit /b 1 +) "%BUNDLED_PHP%" -d phar.readonly=0 box.phar compile --composer-bin "%COMPOSER_BAT%" if errorlevel 1 ( echo [ERROR] PHAR build failed. @@ -29,6 +34,11 @@ if errorlevel 1 ( :: Step 2 - Copy PHAR to Electron resources :: ----------------------------------------------- echo [2/3] Copying PHAR to electron-app\resources\ ... +if not exist "%PHAR_SRC%\movez.phar" ( + echo [ERROR] movez\movez.phar was not produced by Box. + pause + exit /b 1 +) copy /y "%PHAR_SRC%\movez.phar" "%PHAR_DEST%" if errorlevel 1 ( echo [ERROR] Could not copy PHAR. diff --git a/contextbridge/README.md b/contextbridge/README.md deleted file mode 100644 index cda2472..0000000 --- a/contextbridge/README.md +++ /dev/null @@ -1,41 +0,0 @@ -

- Laravel Zero Logo -

- -

- Build Status - Total Downloads - Latest Stable Version - License -

- -Laravel Zero was created by [Nuno Maduro](https://github.com/nunomaduro) and [Owen Voke](https://github.com/owenvoke), and is a micro-framework that provides an elegant starting point for your console application. It is an **unofficial** and customized version of Laravel optimized for building command-line applications. - -- Built on top of the [Laravel](https://laravel.com) components. -- Optional installation of Laravel [Eloquent](https://laravel-zero.com/docs/database/), Laravel [Logging](https://laravel-zero.com/docs/logging/) and many others. -- Supports interactive [menus](https://laravel-zero.com/docs/build-interactive-menus/) and [desktop notifications](https://laravel-zero.com/docs/send-desktop-notifications/) on Linux, Windows & MacOS. -- Ships with a [Scheduler](https://laravel-zero.com/docs/task-scheduling/) and a [Standalone Compiler](https://laravel-zero.com/docs/build-a-standalone-application/). -- Integration with [Collision](https://github.com/nunomaduro/collision) - Beautiful error reporting -- Follow the creator Nuno Maduro: - - YouTube: **[youtube.com/@nunomaduro](https://www.youtube.com/@nunomaduro)** — Videos every weekday - - Twitch: **[twitch.tv/enunomaduro](https://www.twitch.tv/enunomaduro)** — Streams (almost) every weekday - - Twitter / X: **[x.com/enunomaduro](https://x.com/enunomaduro)** - - LinkedIn: **[linkedin.com/in/nunomaduro](https://www.linkedin.com/in/nunomaduro)** - - Instagram: **[instagram.com/enunomaduro](https://www.instagram.com/enunomaduro)** - - Tiktok: **[tiktok.com/@enunomaduro](https://www.tiktok.com/@enunomaduro)** - ------- - -## Documentation - -For full documentation, visit [laravel-zero.com](https://laravel-zero.com/). - -## Support the development -**Do you like this project? Support it by donating** - -- PayPal: [Donate](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L) -- Patreon: [Donate](https://www.patreon.com/nunomaduro) - -## License - -Laravel Zero is an open-source software licensed under the MIT license. diff --git a/contextbridge/app/Commands/DoctorCommand.php b/contextbridge/app/Commands/DoctorCommand.php deleted file mode 100644 index 4a62d82..0000000 --- a/contextbridge/app/Commands/DoctorCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -info('MoveZ Doctor'); - $this->line(''); - - $passed = true; - - // PHP version - $phpVersion = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; - $phpOk = PHP_MAJOR_VERSION >= 8 && PHP_MINOR_VERSION >= 2; - $this->checkResult($phpOk, "PHP >= 8.2 (found {$phpVersion})"); - $passed = $passed && $phpOk; - - // OpenSSL extension - $sslOk = extension_loaded('openssl'); - $this->checkResult($sslOk, 'OpenSSL extension loaded'); - $passed = $passed && $sslOk; - - // PDO SQLite - $sqliteOk = extension_loaded('pdo_sqlite'); - $this->checkResult($sqliteOk, 'PDO SQLite driver loaded'); - $passed = $passed && $sqliteOk; - - // ZipArchive - $zipOk = class_exists('ZipArchive'); - $this->checkResult($zipOk, 'ZipArchive class available'); - $passed = $passed && $zipOk; - - // Key path readable/writable - $keyPath = config('movez.key_path'); - $keyDirOk = is_dir(dirname((string) $keyPath)) || is_writable(dirname(dirname((string) $keyPath))); - $this->checkResult($keyDirOk, 'Encryption key directory accessible'); - $passed = $passed && $keyDirOk; - - // Detected tools - $this->line(''); - $detected = $detector->detect(); - if (!empty($detected)) { - $this->info('Detected AI tools: ' . implode(', ', $detected)); - } else { - $this->warn('No AI tools detected on this machine'); - } - - $this->line(''); - if ($passed) { - $this->info('All checks passed!'); - } else { - $this->error('Some checks failed. See above for details.'); - } - - return $passed ? self::SUCCESS : self::FAILURE; - } - - private function checkResult(bool $ok, string $label): void - { - $icon = $ok ? '✔' : '✘'; - $this->line(" {$icon} {$label}"); - } -} diff --git a/contextbridge/app/Commands/ExportCommand.php b/contextbridge/app/Commands/ExportCommand.php deleted file mode 100644 index f7bbcf9..0000000 --- a/contextbridge/app/Commands/ExportCommand.php +++ /dev/null @@ -1,65 +0,0 @@ -option('tool'); - $outputPath = $this->option('output') ?? ('movez-export-' . date('Ymd-His') . '.json'); - $projectPath = $this->option('project') ?? getcwd(); - $encrypt = (bool) $this->option('encrypt'); - - if ($toolName === 'auto') { - $detected = $detector->detect(); - if (empty($detected)) { - $this->error('No AI coding tools detected on this machine.'); - return self::FAILURE; - } - $toolName = $detected[0]; - $this->info("Auto-detected tool: {$toolName}"); - } - - try { - $parser = $detector->getParser($toolName); - $sessions = $parser->parse((string) $projectPath); - } catch (\InvalidArgumentException $e) { - $this->error($e->getMessage()); - return self::FAILURE; - } - - if ($sessions->isEmpty()) { - $this->warn('No sessions found for the given tool and project.'); - return self::SUCCESS; - } - - $bundle = json_encode( - $sessions->map(fn($s) => $s->toArray())->values()->all(), - JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE - ); - - if ($encrypt) { - $bundle = $encryptor->encrypt((string) $bundle); - $outputPath = str_replace('.json', '.enc.json', $outputPath); - } - - file_put_contents($outputPath, $bundle); - - $this->info("Exported {$sessions->count()} session(s) to: {$outputPath}"); - return self::SUCCESS; - } -} diff --git a/contextbridge/app/Commands/InspireCommand.php b/contextbridge/app/Commands/InspireCommand.php deleted file mode 100644 index 934fe72..0000000 --- a/contextbridge/app/Commands/InspireCommand.php +++ /dev/null @@ -1,48 +0,0 @@ - -
Laravel Zero
- - Simplicity is the ultimate sophistication. - - - HTML); - } - - /** - * Define the command's schedule. - */ - public function schedule(Schedule $schedule): void - { - // $schedule->command(static::class)->everyMinute(); - } -} diff --git a/contextbridge/app/Commands/Sync/SyncPullCommand.php b/contextbridge/app/Commands/Sync/SyncPullCommand.php deleted file mode 100644 index ee0c473..0000000 --- a/contextbridge/app/Commands/Sync/SyncPullCommand.php +++ /dev/null @@ -1,70 +0,0 @@ -resolveToken(); - $baseUrl = $this->option('server') ?? env('CB_SERVER_URL', ''); - $toolName = $this->option('tool'); - $projectPath = $this->option('project') ?? getcwd(); - - if (!$token) { - $this->error('No token found. Use --token or store in ~/.movez/token'); - return self::FAILURE; - } - - if (!$baseUrl) { - $this->error('No server URL. Use --server or set CB_SERVER_URL env var'); - return self::FAILURE; - } - - $client = new SyncClient($baseUrl, $encryptor); - $sessions = $client->pull($token); - - if ($toolName) { - $writer = $detector->getWriter($toolName); - $writer->write($sessions, (string) $projectPath); - $this->info("Pulled and imported {$sessions->count()} session(s) into {$toolName}"); - } else { - // Just output as JSON - $this->line(json_encode( - $sessions->map(fn($s) => $s->toArray())->all(), - JSON_PRETTY_PRINT - )); - } - - return self::SUCCESS; - } - - private function resolveToken(): ?string - { - $token = $this->option('token'); - if ($token) { - return $token; - } - - $tokenPath = config('movez.token_path'); - if ($tokenPath && file_exists($tokenPath)) { - return trim((string) file_get_contents($tokenPath)); - } - - return null; - } -} diff --git a/contextbridge/app/Commands/Sync/SyncPushCommand.php b/contextbridge/app/Commands/Sync/SyncPushCommand.php deleted file mode 100644 index 6ea4458..0000000 --- a/contextbridge/app/Commands/Sync/SyncPushCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -resolveToken(); - $baseUrl = $this->option('server') ?? env('CB_SERVER_URL', ''); - $toolName = $this->option('tool'); - $projectPath = $this->option('project') ?? getcwd(); - - if (!$token) { - $this->error('No token found. Use --token or store in ~/.movez/token'); - return self::FAILURE; - } - - if (!$baseUrl) { - $this->error('No server URL. Use --server or set CB_SERVER_URL env var'); - return self::FAILURE; - } - - if ($toolName === 'auto') { - $detected = $detector->detect(); - if (empty($detected)) { - $this->error('No AI coding tools detected'); - return self::FAILURE; - } - $toolName = $detected[0]; - } - - $sessions = $detector->getParser($toolName)->parse((string) $projectPath); - - $client = new SyncClient($baseUrl, $encryptor); - $client->push($sessions, $token); - - $this->info("Pushed {$sessions->count()} session(s) to {$baseUrl}"); - return self::SUCCESS; - } - - private function resolveToken(): ?string - { - $token = $this->option('token'); - if ($token) { - return $token; - } - - $tokenPath = config('movez.token_path'); - if ($tokenPath && file_exists($tokenPath)) { - return trim((string) file_get_contents($tokenPath)); - } - - return null; - } -} diff --git a/contextbridge/app/Parsers/ClineParser.php b/contextbridge/app/Parsers/ClineParser.php deleted file mode 100644 index e9f23a3..0000000 --- a/contextbridge/app/Parsers/ClineParser.php +++ /dev/null @@ -1,87 +0,0 @@ - */ - public function parseMetadata(string $projectPath): Collection - { - return $this->parse($projectPath); - } - - public function parse(string $projectPath): Collection - { - $os = PlatformPaths::configKey(); - $pattern = config("movez.tools.cline.storage.{$os}", ''); - $dirs = PlatformPaths::globExpand((string) $pattern); - - if (empty($dirs)) { - return collect(); - } - - $sessions = collect(); - - foreach ($dirs as $dir) { - $files = glob($dir . '/*.json') ?: []; - - foreach ($files as $file) { - $raw = file_get_contents($file); - $data = json_decode((string) $raw, true); - - if (!is_array($data)) { - continue; - } - - $turns = collect($data['conversation'] ?? $data['messages'] ?? []) - ->map(fn(array $msg) => new TurnDTO( - role: $msg['role'] ?? 'user', - content: $msg['content'] ?? '', - timestamp: Carbon::parse($msg['timestamp'] ?? 'now'), - )); - - $sessions->push(new SessionDTO( - id: $data['id'] ?? pathinfo($file, PATHINFO_FILENAME), - title: $data['task'] ?? basename($file), - sourceTool: $this->toolName(), - sourceMachineSha: substr(hash('sha256', (string) gethostname()), 0, 16), - createdAt: Carbon::parse($data['ts'] ?? 'now'), - lastActiveAt: Carbon::parse($data['ts'] ?? 'now'), - turns: $turns, - )); - } - } - - return $sessions; - } -} diff --git a/contextbridge/app/Parsers/CodexParser.php b/contextbridge/app/Parsers/CodexParser.php deleted file mode 100644 index 9a9913f..0000000 --- a/contextbridge/app/Parsers/CodexParser.php +++ /dev/null @@ -1,92 +0,0 @@ -getStoragePath($projectPath); - return is_dir($storagePath) && !empty(glob($storagePath . '/*.jsonl')); - } - - /** @return Collection */ - public function parseMetadata(string $projectPath): Collection - { - return $this->parse($projectPath); - } - - public function parse(string $projectPath): Collection - { - $storagePath = $this->getStoragePath($projectPath); - - if (!is_dir($storagePath)) { - return collect(); - } - - $files = glob($storagePath . '/*.jsonl') ?: []; - $sessions = collect(); - - foreach ($files as $file) { - $lines = $this->readJsonlFile($file); - if (empty($lines)) { - continue; - } - - $sessionId = pathinfo($file, PATHINFO_FILENAME); - $turns = collect(); - $createdAt = null; - $lastAt = null; - - foreach ($lines as $line) { - if (!is_array($line) || !isset($line['role'])) { - continue; - } - - $ts = Carbon::parse($line['timestamp'] ?? 'now'); - if ($createdAt === null) { - $createdAt = $ts; - } - $lastAt = $ts; - - $turns->push(new TurnDTO( - role: $line['role'], - content: $line['content'] ?? '', - timestamp: $ts, - )); - } - - $sessions->push(new SessionDTO( - id: $sessionId, - title: $lines[0]['title'] ?? basename($file), - sourceTool: $this->toolName(), - sourceMachineSha: substr(hash('sha256', (string) gethostname()), 0, 16), - createdAt: $createdAt ?? Carbon::now(), - lastActiveAt: $lastAt ?? Carbon::now(), - turns: $turns, - )); - } - - return $sessions; - } -} diff --git a/contextbridge/app/Parsers/WindsurfParser.php b/contextbridge/app/Parsers/WindsurfParser.php deleted file mode 100644 index 7cc3839..0000000 --- a/contextbridge/app/Parsers/WindsurfParser.php +++ /dev/null @@ -1,12 +0,0 @@ - $sessions - */ - public function pack( - Collection $sessions, - string $outputPath, - ?ProjectConfigDTO $config = null, - string $sourceTool = 'unknown', - ): void { - $machineSha = $this->machineSha(); - - $bundleData = [ - 'version' => '1.0', - 'source_tool' => $sourceTool, - 'exported_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ISO8601), - 'sessions' => $sessions->map(fn(SessionDTO $s) => $s->toArray())->values()->all(), - ]; - - BundleSchema::validate($bundleData); - - $manifest = BundleSchema::makeManifest($sourceTool, $machineSha); - - $zip = new ZipArchive(); - if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - throw new RuntimeException("Cannot create archive: {$outputPath}"); - } - - $zip->addFromString('bundle.json', json_encode($bundleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); - $zip->addFromString('manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); - - if ($config !== null) { - $zip->addFromString('config.json', json_encode($config->toArray(), JSON_PRETTY_PRINT)); - } - - $zip->close(); - } - - /** - * Unpack a .cbz archive and return the sessions it contains. - * - * @return Collection - * @throws RuntimeException on corrupted archive - */ - public function unpack(string $cbzPath): Collection - { - if (!file_exists($cbzPath)) { - throw new RuntimeException("Archive not found: {$cbzPath}"); - } - - $zip = new ZipArchive(); - if ($zip->open($cbzPath) !== true) { - throw new RuntimeException("Cannot open archive: {$cbzPath}"); - } - - $bundleJson = $zip->getFromName('bundle.json'); - $zip->close(); - - if ($bundleJson === false) { - throw new RuntimeException('Archive is missing bundle.json'); - } - - $data = json_decode($bundleJson, true); - - if (!is_array($data)) { - throw new RuntimeException('bundle.json contains invalid JSON'); - } - - BundleSchema::validate($data); - - return collect($data['sessions'])->map(fn(array $s) => SessionDTO::fromArray($s)); - } - - private function machineSha(): string - { - $hostname = (string) gethostname(); - return substr(hash('sha256', $hostname), 0, 16); - } -} diff --git a/contextbridge/app/Services/SyncClient.php b/contextbridge/app/Services/SyncClient.php deleted file mode 100644 index 6397940..0000000 --- a/contextbridge/app/Services/SyncClient.php +++ /dev/null @@ -1,70 +0,0 @@ - $sessions - */ - public function push(Collection $sessions, string $token): void - { - $payload = json_encode($sessions->map(fn(SessionDTO $s) => $s->toArray())->all()); - $encrypted = $this->encryptor->encrypt((string) $payload); - - $response = Http::withToken($token) - ->post("{$this->baseUrl}/api/sync/push", ['data' => $encrypted]); - - $this->assertSuccess($response, [401 => 'Unauthorized', 500 => 'Server error']); - } - - /** - * Pull sessions from the sync server (decrypts automatically). - * - * @return Collection - */ - public function pull(string $token): Collection - { - $response = Http::withToken($token) - ->get("{$this->baseUrl}/api/sync/pull"); - - $this->assertSuccess($response, [401 => 'Unauthorized', 500 => 'Server error']); - - $decrypted = $this->encryptor->decrypt($response->json('data')); - $decoded = json_decode($decrypted, true); - - if (!is_array($decoded)) { - throw new RuntimeException('Sync server returned invalid payload'); - } - - return collect($decoded)->map(fn(array $s) => SessionDTO::fromArray($s)); - } - - private function assertSuccess(\Illuminate\Http\Client\Response $response, array $errorMap): void - { - $status = $response->status(); - - foreach ($errorMap as $code => $message) { - if ($status === $code) { - throw new RuntimeException($message); - } - } - - if (!$response->successful()) { - throw new RuntimeException("Sync server error: HTTP {$status}"); - } - } -} diff --git a/contextbridge/app/Writers/CodexWriter.php b/contextbridge/app/Writers/CodexWriter.php deleted file mode 100644 index 1e0973d..0000000 --- a/contextbridge/app/Writers/CodexWriter.php +++ /dev/null @@ -1,45 +0,0 @@ - $sessions */ - public function write(Collection $sessions, string $projectPath): void - { - $storagePath = $this->getStoragePath(); - if (!is_dir($storagePath)) { - mkdir($storagePath, 0755, true); - } - - foreach ($sessions as $session) { - $lines = $session->turns->map(fn($turn) => json_encode([ - 'role' => $turn->role, - 'content' => $turn->content, - 'timestamp' => $turn->timestamp->toIso8601String(), - 'title' => $session->title, - ], JSON_UNESCAPED_UNICODE)); - - $path = $storagePath . DIRECTORY_SEPARATOR . $session->id . '.jsonl'; - file_put_contents($path, implode("\n", $lines->all()) . "\n"); - } - } - - private function getStoragePath(): string - { - $os = PlatformPaths::configKey(); - $cfg = config('movez.tools.codex.storage.' . $os, ''); - return PlatformPaths::expand((string) $cfg); - } -} diff --git a/contextbridge/app/Writers/WindsurfWriter.php b/contextbridge/app/Writers/WindsurfWriter.php deleted file mode 100644 index 9788e9c..0000000 --- a/contextbridge/app/Writers/WindsurfWriter.php +++ /dev/null @@ -1,12 +0,0 @@ -artisan('inspire')->assertExitCode(0); -}); diff --git a/contextbridge/tests/Unit/ExampleTest.php b/contextbridge/tests/Unit/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/contextbridge/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/contextbridge/tests/Unit/Writers/ClaudeCodeWriterTest.php b/contextbridge/tests/Unit/Writers/ClaudeCodeWriterTest.php deleted file mode 100644 index 55a9a88..0000000 --- a/contextbridge/tests/Unit/Writers/ClaudeCodeWriterTest.php +++ /dev/null @@ -1,78 +0,0 @@ -fakePath; - } - - // Expose protected method for testing - public function write(\Illuminate\Support\Collection $sessions, string $projectPath): void - { - if (!is_dir($this->fakePath)) { - mkdir($this->fakePath, 0755, true); - } - - foreach ($sessions as $session) { - $lines = $session->turns->map(function ($turn) use ($session) { - return json_encode([ - 'role' => $turn->role, - 'content' => $turn->content, - 'timestamp' => $turn->timestamp->toIso8601String(), - 'files_referenced' => $turn->filesReferenced, - 'file_diffs' => $turn->fileDiffs - ->map(fn($d) => ['file' => $d->file, 'diff' => $d->diff]) - ->values() - ->all(), - 'reasoning_trace' => $turn->reasoningTrace, - 'tool_calls' => $turn->toolCalls, - 'session_title' => $session->title, - ], JSON_UNESCAPED_UNICODE); - }); - - $path = $this->fakePath . DIRECTORY_SEPARATOR . $session->id . '.jsonl'; - file_put_contents($path, implode("\n", $lines->all()) . "\n"); - } - } - }; - - $session = new SessionDTO( - id: 'sess-xyz', - title: 'ClaudeCode Test', - sourceTool: 'claude-code', - sourceMachineSha: 'abc', - createdAt: Carbon::now(), - lastActiveAt: Carbon::now(), - turns: collect([ - new TurnDTO('user', 'Q', Carbon::now()), - new TurnDTO('assistant', 'A', Carbon::now()), - ]), - ); - - $writer->write(collect([$session]), '/any'); - - expect(file_exists($dir . '/sess-xyz.jsonl'))->toBeTrue(); - - $lines = file($dir . '/sess-xyz.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - expect($lines)->toHaveCount(2); - - $first = json_decode($lines[0], true); - expect($first['role'])->toBe('user') - ->and($first['content'])->toBe('Q'); - }); -}); - -it('toolName returns claude-code', function (): void { - expect((new ClaudeCodeWriter())->toolName())->toBe('claude-code'); -}); diff --git a/docs/extension-setup.md b/docs/extension-setup.md index 67bfb64..36ba42d 100644 --- a/docs/extension-setup.md +++ b/docs/extension-setup.md @@ -1,122 +1,78 @@ -# VS Code Extension Setup +# VS Code / Cursor Extension Setup -Install and configure the MoveZ VS Code/Cursor extension. +The MoveZ extension is a front end for the `movez` CLI. It lists, exports, imports, transfers and syncs sessions from inside the editor. --- -## Installation +## 1. Install the CLI -### From VS Code Marketplace +Download `movez.phar` from the [latest release](https://github.com/kv4u/MoveZ/releases/latest) (needs PHP 8.2+), or use the PHP and PHAR bundled with the Windows desktop app. -Search "MoveZ" in the Extensions panel and click Install. +```bash +php movez.phar doctor +``` -### From VSIX (GitHub Releases) +## 2. Install the extension + +Download `movez-vscode.vsix` from the [latest release](https://github.com/kv4u/MoveZ/releases/latest), then either: ```bash -code --install-extension movez-*.vsix +code --install-extension movez-vscode.vsix # or: cursor --install-extension ... ``` -Or in VS Code: `Ctrl+Shift+P` → "Extensions: Install from VSIX..." +or run **Extensions: Install from VSIX…** from the Command Palette. ---- +## 3. Configure -## Configuration - -Open VS Code Settings (`Ctrl+,`) and search "movez": +Open Settings (`Ctrl+,`) and search for "movez": | Setting | Default | Description | |---|---|---| -| `movez.cliPath` | `movez` | Path to the movez CLI binary | -| `movez.serverUrl` | `""` | URL of your sync server (e.g., `https://sync.example.com`) | -| `movez.token` | `""` | Your API token for the sync server | +| `movez.cliPath` | `movez` | `movez` executable on your PATH, or the full path to `movez.phar` | +| `movez.phpPath` | `php` | PHP used to run a `.phar` | +| `movez.serverUrl` | | Your sync server, e.g. `https://sync.example.com` | -### Example `settings.json` +Example using the Windows desktop app's bundled runtime: ```json { - "movez.cliPath": "/usr/local/bin/movez", - "movez.serverUrl": "https://sync.example.com", - "movez.token": "your_raw_api_token_here" + "movez.cliPath": "C:\\Program Files\\MoveZ\\resources\\movez.phar", + "movez.phpPath": "C:\\Program Files\\MoveZ\\resources\\php\\php.exe", + "movez.serverUrl": "https://sync.example.com" } ``` ---- - -## Features +For sync, run **MoveZ: Set Sync Token**. The token is stored in your OS keychain through VS Code SecretStorage, not in `settings.json`. It is passed to the CLI as the `MOVEZ_TOKEN` environment variable. -### Session Tree View - -The **MoveZ Sessions** panel in the Activity Bar shows all AI sessions detected on your machine. It shells out to: +--- -```bash -movez list-sessions --json -``` +## Usage -Expand each tool node to see individual sessions. +### Sessions view -### Commands +Click the MoveZ icon in the activity bar. The view lists sessions belonging to the **open folder**, newest first, with tool and turn count. Use the refresh button after working in another tool. If the CLI can't be found or fails, the view says so. Details are in the **MoveZ** output channel (View → Output → MoveZ). -Access via `Ctrl+Shift+P`: +### Commands (`Ctrl+Shift+P`) -| Command | Description | +| Command | What it does | |---|---| -| MoveZ: Export Session | Export sessions from a tool to a .cbz bundle | -| MoveZ: Import Session | Import a .cbz bundle into a tool | -| MoveZ: Open Migration Wizard | Open the web-based 5-step migration wizard | -| MoveZ: Sync Push | Push encrypted sessions to sync server | -| MoveZ: Sync Pull | Pull sessions from sync server | -| MoveZ: Refresh Sessions | Refresh the session tree view | - -### Migration Wizard - -The migration wizard opens a WebView panel. If `movez.serverUrl` is configured, it loads the wizard from your server. Otherwise it shows an embedded wizard. +| **MoveZ: Export Sessions** | Pick a tool, choose where to save a `.cbz` bundle, optionally encrypt it. Only this project's sessions are exported. | +| **MoveZ: Import Sessions** | Pick a `.cbz` or `.json` bundle and a target tool (Cursor, Claude Code, Codex, Copilot CLI). Encrypted bundles are detected automatically. | +| **MoveZ: Migration Wizard** | Opens a form: source tool, target tool, project folder, optional path remap. Runs `movez transfer` locally. | +| **MoveZ: Sync Push** | Encrypts and uploads this project's sessions to your sync server. | +| **MoveZ: Sync Pull** | Downloads, decrypts and imports sessions into the chosen tool. | +| **MoveZ: Set Sync Token** | Saves (or clears, if left empty) the sync API token. | +| **MoveZ: Refresh Sessions** | Reloads the Sessions view. | -Steps: -1. **Source Tool** — select where sessions are coming from -2. **Target Tool** — select where to import sessions -3. **Project** — optionally select a specific project -4. **Paths** — configure from/to path remapping for cross-machine migration -5. **Confirm** — review and execute +Sync needs the same `~/.movez/key` on every machine. See [sync-server-setup.md](sync-server-setup.md). --- -## Development - -### Build from Source +## Building from source ```bash cd vscode-extension -npm install -npx tsc --noEmit # type check -npm run compile # build -npx vsce package # produce .vsix +npm ci +npm test # type check +npx vsce package --no-dependencies # → movez-.vsix ``` - -### Run Tests - -```bash -npm test -``` - -### Debug in VS Code - -1. Open `vscode-extension/` in VS Code -2. Press `F5` to launch Extension Development Host -3. The extension will be loaded in the new window - ---- - -## Troubleshooting - -**"movez: command not found"** -- Set `movez.cliPath` to the full path of your CLI binary -- Run `movez doctor` to verify the CLI is working - -**Sessions panel is empty** -- Ensure the CLI is installed and working: `movez doctor` -- Check that AI tools have session data in their default storage paths -- Click the Refresh button in the Sessions panel - -**Sync push/pull fails with 401** -- Verify `movez.token` matches the raw token you created on the server -- Verify `movez.serverUrl` is correct and the server is reachable diff --git a/docs/sync-server-setup.md b/docs/sync-server-setup.md index d0e2044..72999e8 100644 --- a/docs/sync-server-setup.md +++ b/docs/sync-server-setup.md @@ -2,30 +2,35 @@ Self-host the MoveZ sync server for encrypted cross-machine session storage. +The server stores **only ciphertext**. The CLI encrypts sessions with AES-256-GCM using `~/.movez/key` before uploading, so the server can't read your sessions, and neither can anyone who compromises it. + --- ## Requirements -- PHP 8.3+ with extensions: `pdo_mysql`, `openssl`, `redis`, `pcntl` -- MySQL 8.0+ -- Redis 7+ +- PHP 8.2+ with `pdo_mysql` (or `pdo_sqlite`), `openssl`, `mbstring` +- MySQL 8.0+, or SQLite for a single-user setup - Composer 2.x -- Node 20+ (for building frontend assets) +- Node 20+ (to build the dashboard assets) +- Optional: Redis 7+ and `pcntl`/`posix` (Linux/macOS) if you run Laravel Horizon. Sync itself doesn't use queues. + +> **Windows:** `laravel/horizon` requires `ext-pcntl`/`ext-posix`, which don't exist on Windows. Install with +> `composer install --ignore-platform-req=ext-pcntl --ignore-platform-req=ext-posix` and don't run Horizon. --- ## Installation -### 1. Clone and Install Dependencies +### 1. Clone and install ```bash -git clone https://github.com/your-org/movez.git -cd movez/web +git clone https://github.com/kv4u/MoveZ.git +cd MoveZ/web composer install --no-dev --optimize-autoloader -npm install && npm run build +npm ci && npm run build ``` -### 2. Configure Environment +### 2. Configure ```bash cp .env.example .env @@ -36,7 +41,8 @@ Edit `.env`: ```ini APP_ENV=production -APP_URL=https://your-server.com +APP_DEBUG=false +APP_URL=https://sync.example.com DB_CONNECTION=mysql DB_HOST=127.0.0.1 @@ -44,14 +50,11 @@ DB_PORT=3306 DB_DATABASE=movez DB_USERNAME=movez DB_PASSWORD=your_secure_password - -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 - -QUEUE_CONNECTION=redis ``` -### 3. Database Setup +For a single-user server you can keep `DB_CONNECTION=sqlite` and run `touch database/database.sqlite`. + +### 3. Create the database ```bash mysql -u root -p -e "CREATE DATABASE movez CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" @@ -61,45 +64,23 @@ mysql -u root -p -e "GRANT ALL PRIVILEGES ON movez.* TO 'movez'@'localhost';" php artisan migrate --force ``` -### 4. Create API User - -```php -php artisan tinker ->>> $user = App\Models\User::create(['name' => 'Me', 'email' => 'me@example.com', 'password' => bcrypt('secret')]); ->>> $token = Illuminate\Support\Str::random(40); ->>> $user->update(['api_token' => hash('sha256', $token)]); ->>> echo "Your token: $token"; -``` - -Save the raw token — this is what you pass to `movez sync:push --token=...`. - -### 5. Start Laravel Horizon (Queue Worker) +### 4. Create a user and an API token ```bash -php artisan horizon +php artisan tinker --execute="App\Models\User::create(['name' => 'Me', 'email' => 'me@example.com', 'password' => 'change-me']);" +php artisan movez:token me@example.com ``` -For production, use Supervisor: - -```ini -[program:movez-horizon] -command=php /path/to/web/artisan horizon -directory=/path/to/web -user=www-data -autostart=true -autorestart=true -redirect_stderr=true -stdout_logfile=/var/log/movez-horizon.log -``` +`movez:token` prints the token **once**. Only its SHA-256 hash is stored. Running it again replaces the old token. -### 6. Web Server (Nginx) +### 5. Web server (Nginx) ```nginx server { listen 443 ssl; - server_name your-server.com; + server_name sync.example.com; - root /path/to/web/public; + root /path/to/MoveZ/web/public; index index.php; ssl_certificate /etc/ssl/certs/your-cert.pem; @@ -117,53 +98,72 @@ server { } ``` ---- +> The web dashboard has **no login yet**. Restrict it (VPN, IP allow-list or HTTP basic auth on everything except `/api/*`) until authentication lands. The `/api/sync/*` endpoints are protected by API tokens. -## API Reference +--- -### Authentication +## Using it from the CLI -All API endpoints require a Bearer token: +On every machine: +```bash +export MOVEZ_SERVER_URL=https://sync.example.com +export MOVEZ_TOKEN= # or save it in ~/.movez/token ``` -Authorization: Bearer YOUR_RAW_TOKEN + +Copy `~/.movez/key` from the first machine to the others. **Without the same key, pulled sessions can't be decrypted.** + +```bash +movez sync:push --tool=claude-code # upload (replaces the previous upload) +movez sync:pull --tool=claude-code --project=. # download, decrypt, import ``` -The server hashes the token with SHA-256 and looks up `users.api_token`. +--- + +## API reference -### Endpoints +All endpoints need a Bearer token. The server hashes it with SHA-256 and matches `users.api_token`. + +``` +Authorization: Bearer +``` + +### Push -#### Push Sessions ``` POST /api/sync/push Content-Type: application/json -Authorization: Bearer TOKEN -{ "sessions": "" } +{ "sessions": "", "count": 12 } ``` Response: + ```json -{ "status": "ok", "count": 5 } +{ "status": "ok", "count": 12 } ``` -#### Pull Sessions +Each push replaces the user's previous payload. + +### Pull + ``` GET /api/sync/pull -Authorization: Bearer TOKEN ``` -Response: +Response (`sessions` is `null` until the first push): + ```json -{ "sessions": "" } +{ "sessions": "", "count": 12 } ``` +Errors: `401` for a missing or invalid token, `422` for a missing `sessions` field. + --- -## Security Notes +## Security notes -- All session data is AES-256-GCM encrypted **before** leaving the client machine -- The server never has access to plaintext session data -- API tokens are stored as SHA-256 hashes — the raw token is never stored -- Use HTTPS in production — the server should not be accessible over HTTP -- Firewall the Redis port (6379) — it should not be publicly accessible +- Session content is encrypted on the client. The server never sees plaintext or the key. +- API tokens are stored as SHA-256 hashes, and `api_token` is never serialized. +- Serve over HTTPS only. +- If you use Redis, keep it firewalled. diff --git a/electron-app/electron/cli.ts b/electron-app/electron/cli.ts index dfccd16..c504b3a 100644 --- a/electron-app/electron/cli.ts +++ b/electron-app/electron/cli.ts @@ -17,7 +17,7 @@ function bundledPhpPath(): string { const base = app.isPackaged ? process.resourcesPath : join(__dirname, '../../resources') - return join(base, 'php', 'php.exe') + return join(base, 'php', process.platform === 'win32' ? 'php.exe' : 'php') } /** Path to bundled PHAR inside the installed app resources */ @@ -50,13 +50,20 @@ function buildSpawnArgs(cliPath: string, args: string[]): { cmd: string; cmdArgs return { cmd: cliPath, cmdArgs: args } } -export function runCli(args: string[]): Promise { +/** Extra environment for the CLI — used for secrets (MOVEZ_TOKEN) so they never appear in process lists. */ +type CliEnv = Record + +function spawnOptions(env?: CliEnv) { + return { windowsHide: true, env: { ...process.env, ...env } } +} + +export function runCli(args: string[], env?: CliEnv): Promise { return new Promise((resolve) => { const { cmd, cmdArgs } = buildSpawnArgs(getCliPath(), args) const chunks: Buffer[] = [] const errChunks: Buffer[] = [] - const proc = spawn(cmd, cmdArgs, { windowsHide: true }) + const proc = spawn(cmd, cmdArgs, spawnOptions(env)) proc.stdout.on('data', (d) => chunks.push(Buffer.from(d))) proc.stderr.on('data', (d) => errChunks.push(Buffer.from(d))) @@ -78,10 +85,11 @@ export function runCli(args: string[]): Promise { export function streamCli( args: string[], onData: (line: string) => void, - onDone: (code: number) => void + onDone: (code: number) => void, + env?: CliEnv ): void { const { cmd, cmdArgs } = buildSpawnArgs(getCliPath(), args) - const proc = spawn(cmd, cmdArgs, { windowsHide: true }) + const proc = spawn(cmd, cmdArgs, spawnOptions(env)) proc.stdout.on('data', (d: Buffer) => { d.toString('utf8').split('\n').filter(Boolean).forEach(onData) diff --git a/electron-app/electron/ipc/sessions.ts b/electron-app/electron/ipc/sessions.ts index 3e7c38c..8c4d00d 100644 --- a/electron-app/electron/ipc/sessions.ts +++ b/electron-app/electron/ipc/sessions.ts @@ -21,6 +21,16 @@ export function registerSessionHandlers(): void { } }) + // Load one session with all of its turns (list-sessions may only return metadata) + ipcMain.handle('sessions:get', async (_event, tool: string, id: string) => { + const result = await runCli(['show', `--tool=${tool}`, `--id=${id}`, '--json']) + if (result.code !== 0) { + throw new Error(result.stderr || result.stdout || 'Session not found') + } + + return JSON.parse(result.stdout) + }) + // Export sessions to a .cbz bundle ipcMain.handle('sessions:export', async (_event, opts: { tool: string diff --git a/electron-app/electron/ipc/settings.ts b/electron-app/electron/ipc/settings.ts index 5fdf61c..90ce30e 100644 --- a/electron-app/electron/ipc/settings.ts +++ b/electron-app/electron/ipc/settings.ts @@ -19,16 +19,39 @@ const store = new Store({ } }) +// The renderer may only change known keys, with the right types. cliPath and +// phpPath are executed by the main process, so they must be plain strings. +const VALIDATORS: { [K in keyof Settings]: (v: unknown) => v is Settings[K] } = { + cliPath: (v): v is string => typeof v === 'string' && v.length < 1024 && !/[\r\n]/.test(v), + phpPath: (v): v is string => typeof v === 'string' && v.length < 1024 && !/[\r\n]/.test(v), + serverUrl: (v): v is string => typeof v === 'string' && (v === '' || /^https?:\/\/[^\s]+$/i.test(v)), + token: (v): v is string => typeof v === 'string' && v.length < 512, + darkMode: (v): v is boolean => typeof v === 'boolean' +} + +function applySetting(key: string, value: unknown): void { + if (!Object.prototype.hasOwnProperty.call(VALIDATORS, key)) { + throw new Error(`Unknown setting: ${key}`) + } + + const k = key as keyof Settings + if (!VALIDATORS[k](value)) { + throw new Error(`Invalid value for setting ${key}`) + } + + store.set(k, value as Settings[typeof k]) +} + export function registerSettingsHandlers(): void { ipcMain.handle('settings:get', () => store.store) - ipcMain.handle('settings:set', (_event, key: keyof Settings, value: unknown) => { - store.set(key, value) + ipcMain.handle('settings:set', (_event, key: string, value: unknown) => { + applySetting(key, value) return store.store }) - ipcMain.handle('settings:setAll', (_event, settings: Partial) => { - Object.entries(settings).forEach(([k, v]) => store.set(k as keyof Settings, v)) + ipcMain.handle('settings:setAll', (_event, settings: Record) => { + Object.entries(settings ?? {}).forEach(([k, v]) => applySetting(k, v)) return store.store }) } diff --git a/electron-app/electron/ipc/sync.ts b/electron-app/electron/ipc/sync.ts index df21c5b..98f082e 100644 --- a/electron-app/electron/ipc/sync.ts +++ b/electron-app/electron/ipc/sync.ts @@ -8,7 +8,8 @@ export function registerSyncHandlers(): void { tool?: string project?: string }) => { - const args = ['sync:push', `--token=${opts.token}`, `--server=${opts.server}`] + // Token is passed via MOVEZ_TOKEN, never as an argument (visible in process lists) + const args = ['sync:push', `--server=${opts.server}`] if (opts.tool) args.push(`--tool=${opts.tool}`) if (opts.project) args.push(`--project=${opts.project}`) @@ -22,7 +23,8 @@ export function registerSyncHandlers(): void { lines.push(line) win?.webContents.send('sync:log', line) }, - (code) => resolve({ success: code === 0, output: lines.join('\n') }) + (code) => resolve({ success: code === 0, output: lines.join('\n') }), + { MOVEZ_TOKEN: opts.token } ) }) }) @@ -35,7 +37,7 @@ export function registerSyncHandlers(): void { fromPath?: string toPath?: string }) => { - const args = ['sync:pull', `--token=${opts.token}`, `--server=${opts.server}`, `--tool=${opts.tool}`] + const args = ['sync:pull', `--server=${opts.server}`, `--tool=${opts.tool}`] if (opts.project) args.push(`--project=${opts.project}`) if (opts.fromPath) args.push(`--from-path=${opts.fromPath}`) if (opts.toPath) args.push(`--to-path=${opts.toPath}`) @@ -50,7 +52,8 @@ export function registerSyncHandlers(): void { lines.push(line) win?.webContents.send('sync:log', line) }, - (code) => resolve({ success: code === 0, output: lines.join('\n') }) + (code) => resolve({ success: code === 0, output: lines.join('\n') }), + { MOVEZ_TOKEN: opts.token } ) }) }) diff --git a/electron-app/electron/main.ts b/electron-app/electron/main.ts index 038ecd1..f240b6a 100644 --- a/electron-app/electron/main.ts +++ b/electron-app/electron/main.ts @@ -28,7 +28,7 @@ function createWindow(): void { backgroundColor: '#0f172a', webPreferences: { preload: join(__dirname, '../preload/preload.js'), - sandbox: false, + sandbox: true, contextIsolation: true, nodeIntegration: false } diff --git a/electron-app/electron/preload.ts b/electron-app/electron/preload.ts index ac8bf0b..8e9757b 100644 --- a/electron-app/electron/preload.ts +++ b/electron-app/electron/preload.ts @@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron' export const bridge = { // Sessions listSessions: (tool?: string) => ipcRenderer.invoke('sessions:list', tool), + getSession: (tool: string, id: string) => ipcRenderer.invoke('sessions:get', tool, id), exportSessions: (opts: unknown) => ipcRenderer.invoke('sessions:export', opts), importSessions: (opts: unknown) => ipcRenderer.invoke('sessions:import', opts), diff --git a/electron-app/package-lock.json b/electron-app/package-lock.json index 5f22cea..5b85b9d 100644 --- a/electron-app/package-lock.json +++ b/electron-app/package-lock.json @@ -25,7 +25,8 @@ "electron-vite": "^3.0.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" } }, "node_modules/@alloc/quick-lru": { @@ -1994,6 +1995,35 @@ "vue": "^3.2.25" } }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", @@ -2044,12 +2074,81 @@ "@vue/shared": "3.5.30" } }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, "node_modules/@vue/devtools-api": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", @@ -2227,6 +2326,13 @@ "ajv": "^6.9.1" } }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3542,6 +3648,13 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, "node_modules/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", @@ -5011,6 +5124,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6024,6 +6147,13 @@ "dev": true, "license": "MIT" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -6388,6 +6518,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -8076,6 +8213,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", @@ -8138,6 +8282,23 @@ "vue": "^3.5.0" } }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/electron-app/package.json b/electron-app/package.json index a55bdba..1de8bd1 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -10,7 +10,7 @@ "build": "electron-vite build", "preview": "electron-vite preview", "package": "electron-vite build && electron-builder", - "typecheck": "tsc --noEmit" + "typecheck": "vue-tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json" }, "dependencies": { "electron-store": "^8.2.0", @@ -29,7 +29,8 @@ "electron-vite": "^3.0.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" }, "build": { "appId": "com.movez.desktop", @@ -53,17 +54,14 @@ "to": "movez.phar" } ], - "mac": { - "target": "dmg", - "icon": "resources/icon.icns" - }, "win": { "target": "nsis", "icon": "resources/icon.ico" }, - "linux": { - "target": "AppImage", - "icon": "resources/icon.png" + "nsis": { + "artifactName": "MoveZ-Setup-${version}.exe", + "oneClick": false, + "allowToChangeInstallationDirectory": true } } } diff --git a/electron-app/src/pages/Migrate.vue b/electron-app/src/pages/Migrate.vue index 281862d..d20d0e9 100644 --- a/electron-app/src/pages/Migrate.vue +++ b/electron-app/src/pages/Migrate.vue @@ -3,7 +3,7 @@ import { ref, computed } from 'vue' import LogStream from '@/components/LogStream.vue' import ToolBadge from '@/components/ToolBadge.vue' -const TOOLS = ['cursor', 'windsurf', 'claude-code', 'codex', 'copilot-cli', 'cline', 'continue'] +import { READABLE_TOOLS as TOOLS, WRITABLE_TOOLS } from '@/tools' // Wizard state const step = ref(1) @@ -111,7 +111,7 @@ function reset() {

Select target tool

+
@@ -166,7 +176,7 @@ class="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white disabled:opacity-40 hover:bg-green-700 transition-colors" data-testid="confirm-button" > - {{ isSubmitting ? 'Migrating...' : '✓ Start Migration' }} + {{ isSubmitting ? 'Preparing...' : '✓ Get Migration Command' }}
@@ -175,21 +185,26 @@ diff --git a/web/resources/js/app.js b/web/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/web/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/web/resources/js/app.ts b/web/resources/js/app.ts index d8e637d..f591344 100644 --- a/web/resources/js/app.ts +++ b/web/resources/js/app.ts @@ -1,12 +1,13 @@ import '../css/app.css'; -import { createApp, h, DefineComponent } from 'vue'; +import { createApp, h } from 'vue'; +import type { DefineComponent } from 'vue'; import { createInertiaApp } from '@inertiajs/vue3'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; const appName = 'MoveZ'; createInertiaApp({ - title: (title) => `${title} — ${appName}`, + title: (title) => (title ? `${title} — ${appName}` : appName), resolve: (name) => resolvePageComponent( `./Pages/${name}.vue`, diff --git a/web/resources/js/bootstrap.js b/web/resources/js/bootstrap.js deleted file mode 100644 index 5f1390b..0000000 --- a/web/resources/js/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/web/resources/js/types/index.d.ts b/web/resources/js/types/index.d.ts index 7f804ba..932fdca 100644 --- a/web/resources/js/types/index.d.ts +++ b/web/resources/js/types/index.d.ts @@ -18,6 +18,8 @@ export interface TurnDTO { export interface SessionDTO { id: string; title: string; + project?: string; + turn_count?: number; source_tool: string; source_machine_id: string; created_at: string; @@ -27,12 +29,14 @@ export interface SessionDTO { export interface AiSession { id: number; - project_id: number; + project_id: number | null; source_tool: string; session_id: string; title: string; exported_at: string | null; created_at: string; + updated_at: string; + project?: Project | null; } export interface Project { diff --git a/web/resources/views/welcome.blade.php b/web/resources/views/welcome.blade.php deleted file mode 100644 index b7355d7..0000000 --- a/web/resources/views/welcome.blade.php +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/web/routes/api.php b/web/routes/api.php index 5eb131e..4d28a1d 100644 --- a/web/routes/api.php +++ b/web/routes/api.php @@ -1,5 +1,7 @@ comment(Inspiring::quote()); -})->purpose('Display an inspiring quote'); +Artisan::command('movez:token {email : Email of an existing user}', function (string $email): int { + $user = User::where('email', $email)->first(); + + if ($user === null) { + $this->error("No user with email {$email}"); + return 1; + } + + $token = $user->issueApiToken(); + + $this->info("New API token for {$email} (shown once — the server only stores its hash):"); + $this->line($token); + $this->line(''); + $this->line('Use it with: MOVEZ_TOKEN= movez sync:push --server=' . config('app.url')); + + return 0; +})->purpose('Issue a new sync API token for a user (replaces any existing token)'); diff --git a/web/routes/web.php b/web/routes/web.php index 167d8c4..907f5a6 100644 --- a/web/routes/web.php +++ b/web/routes/web.php @@ -1,5 +1,7 @@ create(); + SyncEvent::factory()->create(); + + $this->withoutVite()->get('/') + ->assertStatus(200) + ->assertInertia(fn (Assert $page) => $page + ->component('Dashboard') + ->where('stats.total_sessions', 3) + ->where('stats.total_projects', 3) + ->whereNot('stats.last_sync', null) + ); +}); + +it('session page decodes session data without leaking the raw payload', function (): void { + $session = AiSession::factory()->create(); + + $this->withoutVite()->get("/sessions/{$session->id}") + ->assertStatus(200) + ->assertInertia(fn (Assert $page) => $page + ->component('Sessions/Show') + ->has('session.project') + ->missing('session.session_data') + ->has('sessionData.turns') + ); +}); + +it('session page tolerates malformed session data', function (): void { + $session = AiSession::factory()->create(['session_data' => '"just a string"']); + + $this->withoutVite()->get("/sessions/{$session->id}") + ->assertStatus(200) + ->assertInertia(fn (Assert $page) => $page->where('sessionData', [])); +}); + +it('projects index lists session counts without loading sessions', function (): void { + $project = Project::factory()->create(); + AiSession::factory(2)->create(['project_id' => $project->id]); + + $this->withoutVite()->get('/projects') + ->assertInertia(fn (Assert $page) => $page + ->component('Projects/Index') + ->where('projects.data.0.ai_sessions_count', 2) + ->missing('projects.data.0.ai_sessions') + ); +}); diff --git a/web/tests/Feature/MigrationControllerTest.php b/web/tests/Feature/MigrationControllerTest.php index 3e7c36e..4a69393 100644 --- a/web/tests/Feature/MigrationControllerTest.php +++ b/web/tests/Feature/MigrationControllerTest.php @@ -1,6 +1,7 @@ assertInertia(fn (Assert $page) => $page ->component('Migration/Wizard') ->has('supportedTools') + ->has('writableTools') ->has('projects') ); }); it('migration start requires from_tool and to_tool', function (): void { - $response = $this->postJson('/migration/start', []); - $response->assertStatus(422); + $this->postJson('/migration/start', [])->assertStatus(422); }); -it('migration start with valid data returns queued status', function (): void { - $response = $this->postJson('/migration/start', [ - 'from_tool' => 'cursor', - 'to_tool' => 'claude-code', - ]); +it('migration start rejects tools the CLI cannot write to', function (): void { + $this->postJson('/migration/start', ['from_tool' => 'cursor', 'to_tool' => 'cline']) + ->assertStatus(422) + ->assertJsonValidationErrors('to_tool'); +}); + +it('migration start returns the CLI command to run', function (): void { + $project = Project::factory()->create(['path' => '/home/dev/my app']); - $response->assertStatus(200)->assertJson(['status' => 'queued']); + $this->postJson('/migration/start', [ + 'from_tool' => 'cursor', + 'to_tool' => 'claude-code', + 'project_id' => $project->id, + 'from_path' => '/old/home', + 'to_path' => '/home/dev', + ]) + ->assertStatus(200) + ->assertJson([ + 'status' => 'ready', + 'command' => 'movez transfer --from=cursor --to=claude-code --project="/home/dev/my app" --from-path=/old/home --to-path=/home/dev', + ]); }); diff --git a/web/tests/Feature/SyncServerTest.php b/web/tests/Feature/SyncServerTest.php index b0a2d51..03bd384 100644 --- a/web/tests/Feature/SyncServerTest.php +++ b/web/tests/Feature/SyncServerTest.php @@ -1,41 +1,93 @@ create(); + $user->forceFill(['api_token' => hash('sha256', $token)])->save(); + + return $user; +} + it('sync push returns 401 without a token', function (): void { - $response = $this->postJson('/api/sync/push', ['data' => 'test']); - $response->assertStatus(401); + $this->postJson('/api/sync/push', ['sessions' => 'test'])->assertStatus(401); }); it('sync pull returns 401 without a token', function (): void { - $response = $this->getJson('/api/sync/pull'); - $response->assertStatus(401); + $this->getJson('/api/sync/pull')->assertStatus(401); +}); + +it('sync rejects an unknown token', function (): void { + syncUser('real-token'); + + $this->withToken('wrong-token')->getJson('/api/sync/pull')->assertStatus(401); +}); + +it('sync push requires a sessions payload', function (): void { + syncUser('tok'); + + $this->withToken('tok')->postJson('/api/sync/push', ['data' => 'old-key']) + ->assertStatus(422) + ->assertJsonValidationErrors('sessions'); +}); + +it('sync push stores the encrypted blob and records the event', function (): void { + $user = syncUser('test-token-abc'); + + $this->withToken('test-token-abc') + ->postJson('/api/sync/push', ['sessions' => 'encrypted-blob-xyz', 'count' => 3]) + ->assertStatus(200) + ->assertJson(['status' => 'ok', 'count' => 3]); + + $this->assertDatabaseHas('sync_blobs', ['user_id' => $user->id, 'payload' => 'encrypted-blob-xyz', 'session_count' => 3]); + $this->assertDatabaseHas('sync_events', ['user_id' => $user->id, 'event_type' => 'push', 'session_count' => 3]); + + // Sync payloads are not browsable sessions + expect(AiSession::count())->toBe(0); }); -it('sync push stores encrypted data with valid token', function (): void { - $user = User::factory()->create([ - 'api_token' => hash('sha256', 'test-token-abc'), - ]); +it('sync pull returns the latest blob for the same user', function (): void { + syncUser('test-pull-token'); + + $this->withToken('test-pull-token')->postJson('/api/sync/push', ['sessions' => 'first', 'count' => 1]); + $this->withToken('test-pull-token')->postJson('/api/sync/push', ['sessions' => 'second', 'count' => 2]); - $response = $this->withHeaders(['Authorization' => 'Bearer test-token-abc']) - ->postJson('/api/sync/push', ['data' => 'encrypted-blob-xyz']); + $this->withToken('test-pull-token')->getJson('/api/sync/pull') + ->assertStatus(200) + ->assertExactJson(['sessions' => 'second', 'count' => 2]); +}); + +it('sync pull returns null sessions before anything was pushed', function (): void { + syncUser('fresh'); - $response->assertStatus(200)->assertJson(['status' => 'ok']); + $this->withToken('fresh')->getJson('/api/sync/pull') + ->assertStatus(200) + ->assertExactJson(['sessions' => null, 'count' => 0]); }); -it('sync pull returns data with valid token', function (): void { - $user = User::factory()->create([ - 'api_token' => hash('sha256', 'test-pull-token'), - ]); +it('users cannot pull each other\'s data', function (): void { + syncUser('alice'); + syncUser('bob'); + + $this->withToken('alice')->postJson('/api/sync/push', ['sessions' => 'alice-secret']); + + $this->withToken('bob')->getJson('/api/sync/pull') + ->assertExactJson(['sessions' => null, 'count' => 0]); +}); + +it('api token is never serialized', function (): void { + $user = syncUser('hidden'); + + expect($user->toArray())->not->toHaveKey('api_token'); +}); - // Push first - $this->withHeaders(['Authorization' => 'Bearer test-pull-token']) - ->postJson('/api/sync/push', ['data' => 'my-encrypted-data']); +it('movez:token issues a working token', function (): void { + $user = User::factory()->create(['email' => 'dev@example.com']); - // Then pull - $response = $this->withHeaders(['Authorization' => 'Bearer test-pull-token']) - ->getJson('/api/sync/pull'); + $this->artisan('movez:token', ['email' => 'dev@example.com'])->assertExitCode(0); - $response->assertStatus(200)->assertJson(['data' => 'my-encrypted-data']); + expect($user->fresh()->api_token)->toHaveLength(64); }); diff --git a/web/tests/TestCase.php b/web/tests/TestCase.php index fe1ffc2..6ac0072 100644 --- a/web/tests/TestCase.php +++ b/web/tests/TestCase.php @@ -1,5 +1,7 @@ assertTrue(true); - } -} +it('runs the unit suite', function (): void { + expect(true)->toBeTrue(); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e94166e --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["resources/js/*"] + } + }, + "include": ["resources/js/**/*.ts", "resources/js/**/*.d.ts", "resources/js/**/*.vue"] +}