opencode-multidevice-sync 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +15 -0
- package/README.md +111 -0
- package/README.ru.md +57 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +460 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/render.d.ts +45 -0
- package/dist/cli/render.d.ts.map +1 -0
- package/dist/cli/render.js +167 -0
- package/dist/cli/render.js.map +1 -0
- package/dist/cli/theme.d.ts +45 -0
- package/dist/cli/theme.d.ts.map +1 -0
- package/dist/cli/theme.js +88 -0
- package/dist/cli/theme.js.map +1 -0
- package/dist/core/backup.d.ts +13 -0
- package/dist/core/backup.d.ts.map +1 -0
- package/dist/core/backup.js +102 -0
- package/dist/core/backup.js.map +1 -0
- package/dist/core/fsx.d.ts +81 -0
- package/dist/core/fsx.d.ts.map +1 -0
- package/dist/core/fsx.js +353 -0
- package/dist/core/fsx.js.map +1 -0
- package/dist/core/git.d.ts +62 -0
- package/dist/core/git.d.ts.map +1 -0
- package/dist/core/git.js +162 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/guard.d.ts +5 -0
- package/dist/core/guard.d.ts.map +1 -0
- package/dist/core/guard.js +97 -0
- package/dist/core/guard.js.map +1 -0
- package/dist/core/health.d.ts +18 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +88 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/host.d.ts +20 -0
- package/dist/core/host.d.ts.map +1 -0
- package/dist/core/host.js +50 -0
- package/dist/core/host.js.map +1 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +18 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/jsonc.d.ts +20 -0
- package/dist/core/jsonc.d.ts.map +1 -0
- package/dist/core/jsonc.js +115 -0
- package/dist/core/jsonc.js.map +1 -0
- package/dist/core/lock.d.ts +14 -0
- package/dist/core/lock.d.ts.map +1 -0
- package/dist/core/lock.js +111 -0
- package/dist/core/lock.js.map +1 -0
- package/dist/core/overrides.d.ts +43 -0
- package/dist/core/overrides.d.ts.map +1 -0
- package/dist/core/overrides.js +169 -0
- package/dist/core/overrides.js.map +1 -0
- package/dist/core/paths.d.ts +36 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +55 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/preview.d.ts +3 -0
- package/dist/core/preview.d.ts.map +1 -0
- package/dist/core/preview.js +56 -0
- package/dist/core/preview.js.map +1 -0
- package/dist/core/repo.d.ts +57 -0
- package/dist/core/repo.d.ts.map +1 -0
- package/dist/core/repo.js +196 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/reporter.d.ts +50 -0
- package/dist/core/reporter.d.ts.map +1 -0
- package/dist/core/reporter.js +63 -0
- package/dist/core/reporter.js.map +1 -0
- package/dist/core/sessions.d.ts +83 -0
- package/dist/core/sessions.d.ts.map +1 -0
- package/dist/core/sessions.js +508 -0
- package/dist/core/sessions.js.map +1 -0
- package/dist/core/settings.d.ts +68 -0
- package/dist/core/settings.d.ts.map +1 -0
- package/dist/core/settings.js +126 -0
- package/dist/core/settings.js.map +1 -0
- package/dist/core/setup.d.ts +48 -0
- package/dist/core/setup.d.ts.map +1 -0
- package/dist/core/setup.js +186 -0
- package/dist/core/setup.js.map +1 -0
- package/dist/core/sqlite.d.ts +37 -0
- package/dist/core/sqlite.d.ts.map +1 -0
- package/dist/core/sqlite.js +108 -0
- package/dist/core/sqlite.js.map +1 -0
- package/dist/core/stage.d.ts +44 -0
- package/dist/core/stage.d.ts.map +1 -0
- package/dist/core/stage.js +304 -0
- package/dist/core/stage.js.map +1 -0
- package/dist/core/sync.d.ts +52 -0
- package/dist/core/sync.d.ts.map +1 -0
- package/dist/core/sync.js +423 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/plugin/index.d.ts +29 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +88 -0
- package/dist/plugin/index.js.map +1 -0
- package/docs/MIGRATION.md +29 -0
- package/docs/ROADMAP.md +29 -0
- package/docs/SAFETY.md +31 -0
- package/examples/home-laptop.jsonc +31 -0
- package/examples/opencode-sync.overrides.jsonc +4 -0
- package/examples/work-laptop.jsonc +26 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 doomsday616
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
OpenCode Multidevice Sync
|
|
2
|
+
|
|
3
|
+
This project is derived from opencode-github-sync version 3.0.1:
|
|
4
|
+
https://github.com/doomsday616/opencode-github-sync
|
|
5
|
+
Upstream revision: f9b2c200d3d67e161aaaf26c554c81c5fdea64dd
|
|
6
|
+
Original copyright: Copyright (c) 2026 doomsday616
|
|
7
|
+
Original license: MIT (retained in LICENSE).
|
|
8
|
+
|
|
9
|
+
Modifications: Copyright (c) 2026 logser13 and contributors, MIT.
|
|
10
|
+
Changes include non-destructive session import, deterministic exports,
|
|
11
|
+
local project preservation, verified backups, conflict detection,
|
|
12
|
+
path mapping, diagnostics, preview isolation and regression tests.
|
|
13
|
+
|
|
14
|
+
Independent community fork. Not an official OpenCode component and not
|
|
15
|
+
endorsed by the original author. No warranty; see LICENSE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# OpenCode Multidevice Sync
|
|
2
|
+
|
|
3
|
+
A community fork of `opencode-github-sync` 3.0.1 for moving OpenCode configuration and selected conversations between computers.
|
|
4
|
+
|
|
5
|
+
**Early preview, not a stable production release.** Keep independent backups. Use one active computer at a time. This project is not affiliated with OpenCode or the upstream author.
|
|
6
|
+
|
|
7
|
+
[Русская документация](README.ru.md) | [Migration](docs/MIGRATION.md) | [Safety model](docs/SAFETY.md) | [Roadmap](docs/ROADMAP.md)
|
|
8
|
+
|
|
9
|
+
## What is implemented
|
|
10
|
+
|
|
11
|
+
- Configuration, skills, plugins and machine-local overrides via a private GitHub repository.
|
|
12
|
+
- Selected conversations as compressed JSON shards, never a live SQLite database in Git.
|
|
13
|
+
- Non-destructive SQLite UPSERT and preservation of existing project metadata, including `global`.
|
|
14
|
+
- Deterministic serialization with canonical ordering; unchanged exports stay byte-identical.
|
|
15
|
+
- Whole-batch transactional session import, schema/ownership validation and integrity checks before commit.
|
|
16
|
+
- Verified SQLite snapshots before modifying session data, with configurable backup retention.
|
|
17
|
+
- Per-session content baselines to detect divergent edits, including message changes without a timestamp change.
|
|
18
|
+
- Canonical-to-local path mappings, reversed on export.
|
|
19
|
+
- `doctor`, `backup`, offline `restore`, and isolated `--dry-run` previews.
|
|
20
|
+
- A local process lock and debounced auto-push. This is **not** a distributed multi-writer lock.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
Node.js 22.13+ (24 LTS recommended), Git, GitHub CLI (`gh`), and an initialized OpenCode installation. The plugin dependency is pinned to the OpenCode 1.18.30 SDK. Other OpenCode schema versions require testing; unsupported columns fail closed rather than being silently dropped.
|
|
25
|
+
|
|
26
|
+
The source-code repository is public. Your **separate sync-data repository must be private**. Do not point the tool at this source-code repository.
|
|
27
|
+
|
|
28
|
+
## Install from source
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
git clone https://github.com/logser13/opencode-multidevice-sync.git
|
|
32
|
+
cd opencode-multidevice-sync
|
|
33
|
+
npm ci
|
|
34
|
+
npm run check
|
|
35
|
+
npm pack
|
|
36
|
+
npm install -g ./opencode-multidevice-sync-0.1.0.tgz
|
|
37
|
+
omds --version
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
On Windows PowerShell with script execution disabled, use `npm.cmd` and `omds.cmd`. Changing execution policy is not required.
|
|
41
|
+
|
|
42
|
+
This repository does not imply an npm registry release. Until a registry release is announced, install the locally built tarball or the reviewed GitHub release asset.
|
|
43
|
+
|
|
44
|
+
## First device
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
gh auth login
|
|
48
|
+
omds init my-opencode-config
|
|
49
|
+
omds sessions enable
|
|
50
|
+
omds doctor
|
|
51
|
+
omds push --dry-run
|
|
52
|
+
omds push
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Start OpenCode once before importing conversations so it creates its own database schema and synthetic project. Configuration and session settings are stored in `~/.config/opencode/opencode-sync.jsonc`; the legacy filename is kept for migration compatibility.
|
|
56
|
+
|
|
57
|
+
## Second device
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
gh auth login
|
|
61
|
+
omds link YOUR_ACCOUNT/my-opencode-config
|
|
62
|
+
omds sessions enable
|
|
63
|
+
omds pull --dry-run
|
|
64
|
+
omds pull
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use a different `machineAlias` on each device. Keep credentials local and review machine-specific paths before starting OpenCode. See `examples/work-laptop.jsonc` and `examples/home-laptop.jsonc`.
|
|
68
|
+
|
|
69
|
+
## OpenCode plugin
|
|
70
|
+
|
|
71
|
+
For an installation from a local tarball, find the absolute installed plugin entry:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npm root -g
|
|
75
|
+
# Append /opencode-multidevice-sync/dist/plugin/index.js
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add its absolute file URL to the OpenCode `plugin` array, for example `file:///C:/Users/YOU/AppData/Roaming/npm/node_modules/opencode-multidevice-sync/dist/plugin/index.js`. Do not paste this example without adapting the path. Do not run the upstream sync plugin or its old safe-wrapper alongside this plugin.
|
|
79
|
+
|
|
80
|
+
`autoPullOnStartup` checks for incoming changes at startup. `autoPushOnIdle` is opt-in and waits for a successful initial pull; errors are logged and surfaced as notifications. Restart OpenCode after changes to imported configuration or sessions. Wait for a successful push before switching computers; closing the app or losing the network can interrupt a sync.
|
|
81
|
+
|
|
82
|
+
## Recovery
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
omds doctor --json
|
|
86
|
+
omds backup
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Close every OpenCode process cleanly before restoring:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
omds restore /absolute/path/to/verified-backup.db --offline
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Restore refuses existing WAL/SHM/journal files rather than deleting them. The current database is backed up first. `--force` does not bypass the offline requirement. Never delete database journals to force a restore.
|
|
96
|
+
|
|
97
|
+
## Limitations you must understand
|
|
98
|
+
|
|
99
|
+
A sync transfers history and configuration, not the project's source files, browser profiles, active processes, external tool-output files or API credentials. Transfer project files separately. Path mapping covers session `directory`/`path` and project `worktree`, not every path embedded in message text or tool JSON.
|
|
100
|
+
|
|
101
|
+
Time/count/size limits select what gets exported. Existing remote shards are not automatically deleted when one device lacks a session or its selection window expires. Git history may retain data even after a file is removed.
|
|
102
|
+
|
|
103
|
+
A private repository is access control, not end-to-end encryption. Conversations can contain secrets even when credential-file sync is disabled. The credential scanner is a best-effort guard, not proof that a repository is free of secrets.
|
|
104
|
+
|
|
105
|
+
Conflicting sessions stop the import for manual review. There is no automatic merge of competing conversations. `--dry-run` uses a disposable snapshot and can require additional disk space; previews with `extraPaths` are refused. Local locking does not prevent simultaneous writers on other computers.
|
|
106
|
+
|
|
107
|
+
## Development and origin
|
|
108
|
+
|
|
109
|
+
Run `npm run check` for lint, type checking, build and tests. SQLite tests must execute, not silently skip. CI covers supported Node versions on Windows, Linux and macOS.
|
|
110
|
+
|
|
111
|
+
Based on [doomsday616/opencode-github-sync](https://github.com/doomsday616/opencode-github-sync), version 3.0.1. The original MIT copyright and permission notice remain in [LICENSE](LICENSE). See [NOTICE](NOTICE) for attribution and [CONTRIBUTING.md](CONTRIBUTING.md) for development rules.
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# OpenCode Multidevice Sync
|
|
2
|
+
|
|
3
|
+
Независимый форк `opencode-github-sync 3.0.1` для переноса настроек и выбранных диалогов OpenCode между компьютерами.
|
|
4
|
+
|
|
5
|
+
**Версия 0.1.0 - предварительная, не стабильный production-релиз.** Используйте один активный компьютер и отдельные резервные копии. Проект не связан официально с OpenCode или автором исходного пакета.
|
|
6
|
+
|
|
7
|
+
## Что сделано
|
|
8
|
+
|
|
9
|
+
- Настройки, инструкции, skills и плагины через отдельный приватный GitHub-репозиторий.
|
|
10
|
+
- Диалоги переносятся отдельными JSON.gz-файлами. Живая `opencode.db` не отправляется в Git.
|
|
11
|
+
- Настоящий UPSERT без каскадного удаления сессий. Локальная запись `global` не перезаписывается.
|
|
12
|
+
- Стабильная сериализация: повторный экспорт без изменений не создает новые файлы.
|
|
13
|
+
- Транзакция на весь импорт. Проверяются схема, принадлежность строк, внешние ключи и целостность БД.
|
|
14
|
+
- Проверенные SQLite-снимки перед импортом. Откат транзакции при ошибке.
|
|
15
|
+
- Обнаружение изменений одной сессии на двух компьютерах по хешам содержимого.
|
|
16
|
+
- Сопоставление путей между компьютерами, диагностика и восстановление.
|
|
17
|
+
|
|
18
|
+
## Установка из исходников
|
|
19
|
+
|
|
20
|
+
Нужны Node.js 22.13+ (рекомендуется 24), Git, GitHub CLI и OpenCode. Зависимость SDK зафиксирована на 1.18.30. Совместимость с другими версиями необходимо проверять.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
git clone https://github.com/logser13/opencode-multidevice-sync.git
|
|
24
|
+
cd opencode-multidevice-sync
|
|
25
|
+
npm ci
|
|
26
|
+
npm run check
|
|
27
|
+
npm pack
|
|
28
|
+
npm install -g ./opencode-multidevice-sync-0.1.0.tgz
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
В PowerShell используйте `npm.cmd` и `omds.cmd`, если Windows блокирует `.ps1`. Публикация исходников не означает публикацию пакета в npm.
|
|
32
|
+
|
|
33
|
+
## Настройка
|
|
34
|
+
|
|
35
|
+
На первом компьютере: `gh auth login`, затем `omds init my-opencode-config`, `omds sessions enable`, `omds doctor`, `omds push --dry-run` и `omds push`.
|
|
36
|
+
|
|
37
|
+
На втором: установите тот же пакет, запустите OpenCode один раз для создания БД, выполните `gh auth login`, `omds link YOUR_ACCOUNT/my-opencode-config`, `omds sessions enable`, `omds pull --dry-run` и `omds pull`.
|
|
38
|
+
|
|
39
|
+
Репозиторий с исходниками и репозиторий с личными настройками - разные репозитории. Личные настройки и диалоги нельзя отправлять в публичный репозиторий проекта.
|
|
40
|
+
|
|
41
|
+
Локальные параметры находятся в `~/.config/opencode/opencode-sync.jsonc`. Имя сохранено для совместимости. Укажите разные `machineAlias`. Секреты и состояние интерфейса по умолчанию не переносятся.
|
|
42
|
+
|
|
43
|
+
Для подключения плагина добавьте в `plugin` абсолютный `file:///.../opencode-multidevice-sync/dist/plugin/index.js` установленного пакета. Корень глобальных npm-пакетов показывает `npm root -g`. Не запускайте одновременно исходный sync, старую safe-обертку и новый плагин.
|
|
44
|
+
|
|
45
|
+
## Ежедневная работа
|
|
46
|
+
|
|
47
|
+
`autoPullOnStartup` проверяет обновления при запуске. `autoPushOnIdle` включается отдельно. Перед переходом на другой компьютер дождитесь успешной отправки. После импорта перезапустите OpenCode. При конфликте обе версии сохраняются для ручного разбора, автоматического слияния диалогов нет.
|
|
48
|
+
|
|
49
|
+
`omds doctor --json` проверяет состояние. `omds backup` делает проверенный снимок. Для восстановления закройте OpenCode и выполните `omds restore ПУТЬ_К_КОПИИ --offline`. При наличии WAL/SHM восстановление откажется работать: не удаляйте эти файлы вручную.
|
|
50
|
+
|
|
51
|
+
## Ограничения
|
|
52
|
+
|
|
53
|
+
Локальная блокировка не защищает от двух одновременно работающих компьютеров. Файлы проектов, браузерные профили, активные процессы и внешние результаты инструментов не синхронизируются. Пути внутри произвольного текста сообщений не преобразуются.
|
|
54
|
+
|
|
55
|
+
Приватный GitHub - не сквозное шифрование. Секреты могут оказаться в самих диалогах. Фильтры по времени и размеру ограничивают новые экспорты, но не удаляют автоматически прежние архивы и историю Git.
|
|
56
|
+
|
|
57
|
+
[Инструкция миграции](docs/MIGRATION.md), [модель безопасности](docs/SAFETY.md), [планы](docs/ROADMAP.md). Проект основан на MIT-коде doomsday616; исходное уведомление сохранено в LICENSE и NOTICE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { createDatabaseBackup, latestDatabaseBackup, listDatabaseBackups, restoreDatabaseBackup, } from "../core/backup.js";
|
|
7
|
+
import { git } from "../core/git.js";
|
|
8
|
+
import { assertPrivateRemote } from "../core/guard.js";
|
|
9
|
+
import { checkDatabaseHealth, healthProblems } from "../core/health.js";
|
|
10
|
+
import { withSyncLock } from "../core/lock.js";
|
|
11
|
+
import { getDatabasePath, getRoots } from "../core/paths.js";
|
|
12
|
+
import { listSessions } from "../core/sessions.js";
|
|
13
|
+
import { repoUrl } from "../core/settings.js";
|
|
14
|
+
import { OVERRIDES_TEMPLATE, loadSettings, overridesPath, saveSettings, settingsPath, } from "../core/settings.js";
|
|
15
|
+
import { SetupError, discoverSyncRepos, initSync, linkSync } from "../core/setup.js";
|
|
16
|
+
import { sqliteAvailable } from "../core/sqlite.js";
|
|
17
|
+
import { SyncError, formatBytes, pull, push, status } from "../core/sync.js";
|
|
18
|
+
import { Spinner, banner, createCliReporter, detail, fail, heading, info, keyValue, ok, outcome, rule, warn, write, } from "./render.js";
|
|
19
|
+
import { glyph, paint, style } from "./theme.js";
|
|
20
|
+
const LOCK_WAIT_MS = 5 * 60 * 1000;
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const positional = [];
|
|
23
|
+
const flags = new Set();
|
|
24
|
+
const values = new Map();
|
|
25
|
+
for (const arg of argv) {
|
|
26
|
+
if (!arg.startsWith("-")) {
|
|
27
|
+
positional.push(arg);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const clean = arg.replace(/^--?/, "");
|
|
31
|
+
const eq = clean.indexOf("=");
|
|
32
|
+
if (eq !== -1)
|
|
33
|
+
values.set(clean.slice(0, eq), clean.slice(eq + 1));
|
|
34
|
+
else
|
|
35
|
+
flags.add(clean);
|
|
36
|
+
}
|
|
37
|
+
return { command: positional.shift() ?? "help", positional, flags, values };
|
|
38
|
+
}
|
|
39
|
+
async function confirm(question, lines) {
|
|
40
|
+
if (!process.stdin.isTTY)
|
|
41
|
+
return false;
|
|
42
|
+
write();
|
|
43
|
+
write(` ${paint(" FORCE ", style.bold, style.red)} ${paint(question, style.bold, style.text)}`);
|
|
44
|
+
for (const line of lines)
|
|
45
|
+
write(` ${paint(`${glyph.bullet} ${line}`, style.muted)}`);
|
|
46
|
+
write();
|
|
47
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
48
|
+
const answer = await new Promise((resolve) => {
|
|
49
|
+
rl.question(` ${paint("Type", style.muted)} ${paint("yes", style.bold, style.red)} ${paint("to continue: ", style.muted)}`, resolve);
|
|
50
|
+
});
|
|
51
|
+
rl.close();
|
|
52
|
+
write();
|
|
53
|
+
return answer.trim().toLowerCase() === "yes";
|
|
54
|
+
}
|
|
55
|
+
// ── Commands ────────────────────────────────────────────────────────────────
|
|
56
|
+
async function runSync(action, args) {
|
|
57
|
+
const force = args.flags.has("force") || args.flags.has("f");
|
|
58
|
+
const dryRun = args.flags.has("dry-run") || args.flags.has("n");
|
|
59
|
+
const roots = getRoots();
|
|
60
|
+
banner(action === "push" ? "push to GitHub" : "pull from GitHub", force ? "FORCE" : undefined);
|
|
61
|
+
if (force && !dryRun) {
|
|
62
|
+
const consequences = action === "push"
|
|
63
|
+
? ["Any change made on another machine and not pulled here will be overwritten."]
|
|
64
|
+
: [
|
|
65
|
+
"Uncommitted local changes will be discarded.",
|
|
66
|
+
"Skills and state directories will be rewritten from the repository.",
|
|
67
|
+
];
|
|
68
|
+
if (!(await confirm(`Force ${action}?`, consequences))) {
|
|
69
|
+
info("Cancelled.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const spinner = new Spinner(action === "push" ? "Preparing push" : "Preparing pull");
|
|
74
|
+
spinner.start();
|
|
75
|
+
const reporter = createCliReporter(spinner);
|
|
76
|
+
try {
|
|
77
|
+
const result = await withSyncLock(roots.config, { waitMs: LOCK_WAIT_MS }, () => action === "push"
|
|
78
|
+
? push({ force, dryRun, reporter, roots })
|
|
79
|
+
: pull({ force, dryRun, reporter, roots }));
|
|
80
|
+
spinner.stop();
|
|
81
|
+
if (result.sessions) {
|
|
82
|
+
const bits = [];
|
|
83
|
+
if (result.sessions.exported !== undefined)
|
|
84
|
+
bits.push(`${result.sessions.exported} exported`);
|
|
85
|
+
if (result.sessions.imported !== undefined)
|
|
86
|
+
bits.push(`${result.sessions.imported} imported`);
|
|
87
|
+
if (result.sessions.skipped)
|
|
88
|
+
bits.push(`${result.sessions.skipped} skipped`);
|
|
89
|
+
if (bits.length > 0)
|
|
90
|
+
detail(`Sessions: ${bits.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
outcome(dryRun ? `${result.message} (dry run)` : result.message, result.changed);
|
|
93
|
+
if (result.restartRequired && result.changed) {
|
|
94
|
+
info("Restart OpenCode for the new configuration to take effect.");
|
|
95
|
+
write();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
spinner.stop();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function runStatus() {
|
|
103
|
+
banner("status");
|
|
104
|
+
const roots = getRoots();
|
|
105
|
+
const state = status({ roots });
|
|
106
|
+
if (!state.configured) {
|
|
107
|
+
fail("No sync repository configured.");
|
|
108
|
+
write();
|
|
109
|
+
info("Run `omds init` to create one.");
|
|
110
|
+
const found = discoverSyncRepos();
|
|
111
|
+
if (found.length > 0) {
|
|
112
|
+
write();
|
|
113
|
+
detail(`Existing private repositories that look like a match: ${found.slice(0, 5).join(", ")}`);
|
|
114
|
+
detail("Link one with: omds link <owner/repo>");
|
|
115
|
+
}
|
|
116
|
+
write();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
keyValue("repository", paint(state.remote ?? "-", style.text));
|
|
120
|
+
keyValue("branch", paint(state.branch, style.text));
|
|
121
|
+
keyValue("machine", paint(state.machineAlias, style.text));
|
|
122
|
+
if (!state.initialized) {
|
|
123
|
+
write();
|
|
124
|
+
warn("The local repository is not initialised yet. Run `omds pull`.");
|
|
125
|
+
write();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const sync = state.ahead === 0 && state.behind === 0
|
|
129
|
+
? paint("in sync", style.green)
|
|
130
|
+
: [
|
|
131
|
+
state.ahead > 0 ? paint(`${state.ahead} to push`, style.yellow) : "",
|
|
132
|
+
state.behind > 0 ? paint(`${state.behind} to pull`, style.cyan) : "",
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(paint(" ", style.faint));
|
|
136
|
+
keyValue("state", sync);
|
|
137
|
+
keyValue("uncommitted", state.dirty === 0 ? paint("clean", style.green) : paint(`${state.dirty} file(s)`, style.yellow));
|
|
138
|
+
keyValue("overrides", state.overridesActive ? paint("active", style.accent) : paint("none", style.muted));
|
|
139
|
+
keyValue("sessions", state.sessionsEnabled
|
|
140
|
+
? paint(`enabled · ${state.sessionShards} shard(s)`, style.text)
|
|
141
|
+
: paint("disabled", style.muted));
|
|
142
|
+
if (state.lastCommit) {
|
|
143
|
+
heading("Last commit");
|
|
144
|
+
detail(state.lastCommit);
|
|
145
|
+
if (state.lastCommitDate)
|
|
146
|
+
detail(state.lastCommitDate);
|
|
147
|
+
}
|
|
148
|
+
write();
|
|
149
|
+
}
|
|
150
|
+
function runInit(args, mode) {
|
|
151
|
+
banner(mode === "init" ? "create a sync repository" : "link an existing repository");
|
|
152
|
+
const reporter = createCliReporter();
|
|
153
|
+
const options = {
|
|
154
|
+
repo: args.positional[0],
|
|
155
|
+
reporter,
|
|
156
|
+
includeCredentials: args.flags.has("credentials"),
|
|
157
|
+
includeSessions: args.flags.has("sessions"),
|
|
158
|
+
};
|
|
159
|
+
const result = mode === "init" ? initSync(options) : linkSync(options);
|
|
160
|
+
ok(result.created
|
|
161
|
+
? `Created ${paint(`${result.target.owner}/${result.target.name}`, style.bold, style.text)}`
|
|
162
|
+
: `Linked ${paint(`${result.target.owner}/${result.target.name}`, style.bold, style.text)}`);
|
|
163
|
+
detail(`settings ${result.settingsFile}`);
|
|
164
|
+
detail(`overrides ${result.overridesFile}`);
|
|
165
|
+
heading("Next");
|
|
166
|
+
write(` ${paint("1.", style.faint)} ${mode === "init" ? "omds push" : "omds pull"}`);
|
|
167
|
+
write(` ${paint("2.", style.faint)} Restart OpenCode`);
|
|
168
|
+
write();
|
|
169
|
+
}
|
|
170
|
+
async function runSessions(args) {
|
|
171
|
+
const sub = args.positional[0] ?? "list";
|
|
172
|
+
const roots = getRoots();
|
|
173
|
+
const settings = loadSettings(roots.config);
|
|
174
|
+
if (sub === "list") {
|
|
175
|
+
banner("sessions");
|
|
176
|
+
if (!(await sqliteAvailable())) {
|
|
177
|
+
fail("No SQLite driver available. Use Node 22.5+ or run under Bun.");
|
|
178
|
+
write();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const limit = Number(args.values.get("limit") ?? 20);
|
|
182
|
+
const sessions = await listSessions(getDatabasePath(roots), limit);
|
|
183
|
+
if (sessions.length === 0) {
|
|
184
|
+
info("No sessions found.");
|
|
185
|
+
write();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const session of sessions) {
|
|
189
|
+
const when = new Date(session.timeUpdated).toISOString().slice(0, 16).replace("T", " ");
|
|
190
|
+
const title = session.title || paint("(untitled)", style.faint);
|
|
191
|
+
write(` ${paint(when, style.faint)} ${title}`);
|
|
192
|
+
write(` ${paint(" ".repeat(16), style.faint)} ${paint(session.id, style.faint)}`);
|
|
193
|
+
}
|
|
194
|
+
write();
|
|
195
|
+
detail(`Pin a session with: omds sessions include <id>`);
|
|
196
|
+
write();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (sub === "include" || sub === "exclude") {
|
|
200
|
+
const id = args.positional[1];
|
|
201
|
+
if (!id)
|
|
202
|
+
throw new SyncError(`Usage: omds sessions ${sub} <session-id>`);
|
|
203
|
+
const list = sub === "include" ? settings.sessions.include : settings.sessions.exclude;
|
|
204
|
+
if (!list.includes(id))
|
|
205
|
+
list.push(id);
|
|
206
|
+
saveSettings(settings, roots.config);
|
|
207
|
+
banner("sessions");
|
|
208
|
+
ok(`${sub === "include" ? "Pinned" : "Excluded"} ${paint(id, style.text)}`);
|
|
209
|
+
write();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (sub === "enable" || sub === "disable") {
|
|
213
|
+
settings.sessions.enabled = sub === "enable";
|
|
214
|
+
saveSettings(settings, roots.config);
|
|
215
|
+
banner("sessions");
|
|
216
|
+
ok(`Session sync ${sub}d`);
|
|
217
|
+
if (sub === "enable") {
|
|
218
|
+
detail(`Window: ${settings.sessions.days} day(s), up to ${settings.sessions.maxSessions} sessions`);
|
|
219
|
+
detail(`Per-session cap: ${formatBytes(settings.sessions.maxSessionBytes)}`);
|
|
220
|
+
warn("Only enable this for a private repository — conversations are private data.");
|
|
221
|
+
}
|
|
222
|
+
write();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
throw new SyncError(`Unknown sessions subcommand: ${sub}`);
|
|
226
|
+
}
|
|
227
|
+
async function runDoctor(args) {
|
|
228
|
+
const roots = getRoots();
|
|
229
|
+
const settings = loadSettings(roots.config);
|
|
230
|
+
const remote = repoUrl(settings);
|
|
231
|
+
const issues = [];
|
|
232
|
+
const database = getDatabasePath(roots);
|
|
233
|
+
let health;
|
|
234
|
+
try {
|
|
235
|
+
health = await checkDatabaseHealth(database);
|
|
236
|
+
if (settings.sessions.enabled && !health.ok)
|
|
237
|
+
issues.push(...healthProblems(health));
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
issues.push(error.message);
|
|
241
|
+
}
|
|
242
|
+
if (!remote)
|
|
243
|
+
issues.push("Sync repository is not configured");
|
|
244
|
+
else {
|
|
245
|
+
const reachable = git(["ls-remote", remote, "HEAD"], { cwd: roots.config });
|
|
246
|
+
if (!reachable.ok)
|
|
247
|
+
issues.push("Remote is unreachable or authentication failed");
|
|
248
|
+
try {
|
|
249
|
+
assertPrivateRemote(remote, settings);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
issues.push(error.message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const report = {
|
|
256
|
+
package: "opencode-multidevice-sync",
|
|
257
|
+
version: "0.1.0",
|
|
258
|
+
machine: settings.machineAlias ?? "auto",
|
|
259
|
+
database: health,
|
|
260
|
+
sessionSync: settings.sessions.enabled,
|
|
261
|
+
backupBeforeImport: settings.sessions.backupBeforeImport,
|
|
262
|
+
credentialsSync: settings.includeCredentials,
|
|
263
|
+
backups: listDatabaseBackups().length,
|
|
264
|
+
issues,
|
|
265
|
+
ok: issues.length === 0,
|
|
266
|
+
note: "Preflight checks only; not a guarantee of conflict-free concurrent editing. Keep one active device.",
|
|
267
|
+
};
|
|
268
|
+
if (args.flags.has("json"))
|
|
269
|
+
console.log(JSON.stringify(report, null, 2));
|
|
270
|
+
else {
|
|
271
|
+
banner("doctor");
|
|
272
|
+
keyValue("version", report.version);
|
|
273
|
+
keyValue("machine", report.machine);
|
|
274
|
+
keyValue("sessions", String(health?.sessions ?? 0));
|
|
275
|
+
keyValue("messages", String(health?.messages ?? 0));
|
|
276
|
+
keyValue("parts", String(health?.parts ?? 0));
|
|
277
|
+
keyValue("database integrity", health?.ok ? "OK" : "NOT OK");
|
|
278
|
+
keyValue("backups", String(report.backups));
|
|
279
|
+
if (report.ok)
|
|
280
|
+
ok("Preflight checks passed");
|
|
281
|
+
else
|
|
282
|
+
for (const issue of issues)
|
|
283
|
+
fail(issue);
|
|
284
|
+
info(report.note);
|
|
285
|
+
write();
|
|
286
|
+
}
|
|
287
|
+
if (!report.ok)
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
}
|
|
290
|
+
async function runBackup() {
|
|
291
|
+
const roots = getRoots();
|
|
292
|
+
const settings = loadSettings(roots.config);
|
|
293
|
+
const result = await withSyncLock(roots.config, { waitMs: LOCK_WAIT_MS }, () => createDatabaseBackup(getDatabasePath(roots), undefined, settings.sessions.backupRetention));
|
|
294
|
+
banner("backup");
|
|
295
|
+
ok("Verified SQLite snapshot created");
|
|
296
|
+
detail(result.path);
|
|
297
|
+
detail(formatBytes(result.bytes));
|
|
298
|
+
}
|
|
299
|
+
function assertOpenCodeClosed() {
|
|
300
|
+
const windows = process.platform === "win32";
|
|
301
|
+
const check = spawnSync(windows ? "tasklist.exe" : "ps", windows ? ["/FO", "CSV", "/NH"] : ["-A", "-o", "comm="], { encoding: "utf8", windowsHide: true, timeout: 10000 });
|
|
302
|
+
if (check.status !== 0)
|
|
303
|
+
throw new SyncError("Cannot check running applications; restore refused");
|
|
304
|
+
if (windows
|
|
305
|
+
? /^"(?:opencode|opencode-cli)\.exe"/im.test(check.stdout)
|
|
306
|
+
: /(?:^|\/)(?:opencode|OpenCode)(?:\n|$)/m.test(check.stdout))
|
|
307
|
+
throw new SyncError("OpenCode is running. Close it cleanly before restoring.");
|
|
308
|
+
}
|
|
309
|
+
async function runRestore(args) {
|
|
310
|
+
if (!args.flags.has("offline"))
|
|
311
|
+
throw new SyncError("Restore requires --offline and all OpenCode processes closed. --force cannot bypass this check.");
|
|
312
|
+
assertOpenCodeClosed();
|
|
313
|
+
const roots = getRoots();
|
|
314
|
+
const backup = args.positional[0]
|
|
315
|
+
? { path: path.resolve(args.positional[0]) }
|
|
316
|
+
: latestDatabaseBackup();
|
|
317
|
+
if (!backup)
|
|
318
|
+
throw new SyncError("No database backup found");
|
|
319
|
+
if (!args.flags.has("yes") &&
|
|
320
|
+
!(await confirm("Restore verified backup?", [
|
|
321
|
+
backup.path,
|
|
322
|
+
"Existing database will be backed up first. No WAL files will be deleted.",
|
|
323
|
+
])))
|
|
324
|
+
return;
|
|
325
|
+
const safety = await withSyncLock(roots.config, { waitMs: LOCK_WAIT_MS }, () => restoreDatabaseBackup(getDatabasePath(roots), backup.path, { offlineConfirmed: true }));
|
|
326
|
+
ok("Database restored");
|
|
327
|
+
if (safety)
|
|
328
|
+
detail(`Previous database: ${safety}`);
|
|
329
|
+
}
|
|
330
|
+
function runOverrides() {
|
|
331
|
+
banner("per-machine overrides");
|
|
332
|
+
const roots = getRoots();
|
|
333
|
+
const file = overridesPath(roots.config);
|
|
334
|
+
if (!fs.existsSync(file)) {
|
|
335
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
336
|
+
fs.writeFileSync(file, OVERRIDES_TEMPLATE);
|
|
337
|
+
ok("Created the overrides file");
|
|
338
|
+
}
|
|
339
|
+
detail(file);
|
|
340
|
+
write();
|
|
341
|
+
info("Anything in this file stays on this machine and is re-applied after every pull.");
|
|
342
|
+
write();
|
|
343
|
+
}
|
|
344
|
+
function runConfig() {
|
|
345
|
+
banner("settings");
|
|
346
|
+
const roots = getRoots();
|
|
347
|
+
const file = settingsPath(roots.config);
|
|
348
|
+
if (!fs.existsSync(file)) {
|
|
349
|
+
fail("No settings file yet. Run `omds init`.");
|
|
350
|
+
write();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
detail(file);
|
|
354
|
+
write();
|
|
355
|
+
write(fs.readFileSync(file, "utf8").trimEnd());
|
|
356
|
+
write();
|
|
357
|
+
}
|
|
358
|
+
function runHelp() {
|
|
359
|
+
banner("sync OpenCode across machines");
|
|
360
|
+
heading("Setup");
|
|
361
|
+
command("init [name|owner/repo]", "Create a private sync repository");
|
|
362
|
+
command("link <owner/repo>", "Use an existing sync repository");
|
|
363
|
+
heading("Sync");
|
|
364
|
+
command("push", "Send this machine's configuration");
|
|
365
|
+
command("pull", "Apply the shared configuration");
|
|
366
|
+
command("status", "Show what is in sync and what is not");
|
|
367
|
+
heading("Sessions");
|
|
368
|
+
command("sessions list", "Show recent sessions and their ids");
|
|
369
|
+
command("sessions enable | disable", "Turn selective session sync on or off");
|
|
370
|
+
command("sessions include <id>", "Always sync a session, ignoring the time window");
|
|
371
|
+
command("sessions exclude <id>", "Never sync a session");
|
|
372
|
+
heading("Safety and recovery");
|
|
373
|
+
command("doctor [--json]", "Check repository and database integrity");
|
|
374
|
+
command("backup", "Create a consistent verified SQLite backup");
|
|
375
|
+
command("restore [file] --offline", "Restore after OpenCode is fully closed");
|
|
376
|
+
heading("Machine-local");
|
|
377
|
+
command("overrides", "Open the per-machine configuration patch");
|
|
378
|
+
command("config", "Show the current settings");
|
|
379
|
+
heading("Flags");
|
|
380
|
+
command("--force", "Overwrite the other side on conflict");
|
|
381
|
+
command("--dry-run", "Report what would change without doing it");
|
|
382
|
+
write();
|
|
383
|
+
rule();
|
|
384
|
+
write(` ${paint("docs", style.faint)} ${paint("https://github.com/logser13/opencode-multidevice-sync", style.muted)}`);
|
|
385
|
+
write();
|
|
386
|
+
}
|
|
387
|
+
function command(name, description) {
|
|
388
|
+
const padded = name.padEnd(30);
|
|
389
|
+
write(` ${paint(padded, style.cyan)}${paint(description, style.muted)}`);
|
|
390
|
+
}
|
|
391
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
392
|
+
async function main() {
|
|
393
|
+
const args = parseArgs(process.argv.slice(2));
|
|
394
|
+
if (args.flags.has("version")) {
|
|
395
|
+
console.log("0.1.0");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
switch (args.command) {
|
|
399
|
+
case "doctor":
|
|
400
|
+
await runDoctor(args);
|
|
401
|
+
return;
|
|
402
|
+
case "backup":
|
|
403
|
+
await runBackup();
|
|
404
|
+
return;
|
|
405
|
+
case "restore":
|
|
406
|
+
await runRestore(args);
|
|
407
|
+
return;
|
|
408
|
+
case "push":
|
|
409
|
+
case "pull":
|
|
410
|
+
await runSync(args.command, args);
|
|
411
|
+
return;
|
|
412
|
+
case "status":
|
|
413
|
+
runStatus();
|
|
414
|
+
return;
|
|
415
|
+
case "init":
|
|
416
|
+
runInit(args, "init");
|
|
417
|
+
return;
|
|
418
|
+
case "link":
|
|
419
|
+
runInit(args, "link");
|
|
420
|
+
return;
|
|
421
|
+
case "sessions":
|
|
422
|
+
await runSessions(args);
|
|
423
|
+
return;
|
|
424
|
+
case "overrides":
|
|
425
|
+
runOverrides();
|
|
426
|
+
return;
|
|
427
|
+
case "config":
|
|
428
|
+
runConfig();
|
|
429
|
+
return;
|
|
430
|
+
case "help":
|
|
431
|
+
case "--help":
|
|
432
|
+
case "-h":
|
|
433
|
+
runHelp();
|
|
434
|
+
return;
|
|
435
|
+
default:
|
|
436
|
+
banner();
|
|
437
|
+
fail(`Unknown command: ${args.command}`);
|
|
438
|
+
write();
|
|
439
|
+
info("Run `omds help` to see everything.");
|
|
440
|
+
write();
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
main().catch((error) => {
|
|
445
|
+
const err = error;
|
|
446
|
+
write();
|
|
447
|
+
if (err instanceof SyncError || err instanceof SetupError || err.name === "GitAuthError") {
|
|
448
|
+
fail(err.message);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
fail(err.message || String(error));
|
|
452
|
+
if (process.env.OPENCODE_SYNC_VERBOSE === "1" && err.stack) {
|
|
453
|
+
write();
|
|
454
|
+
detail(err.stack);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
write();
|
|
458
|
+
process.exit(1);
|
|
459
|
+
});
|
|
460
|
+
//# sourceMappingURL=index.js.map
|