p2p-envsync 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 RismanRJ
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/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # EnvSync
2
+
3
+ P2P, offline-first, encrypted `.env` sync for dev teams — no central server, LAN multicast for instant sync, GitHub as encrypted backup/relay.
4
+
5
+ ![npm](https://img.shields.io/badge/npm-envsync-blue) ![license](https://img.shields.io/badge/license-MIT-green) ![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npm install -g envsync
11
+ ```
12
+
13
+ Prerequisites: Node.js 18+, `gh` CLI (for GitHub backup features).
14
+
15
+ ## Quick Start
16
+
17
+ ```
18
+ # User A: create a room from an existing .env file
19
+ envsync create myproject .env
20
+
21
+ # User A shares the room name + key with User B (via `envsync invite`)
22
+
23
+ # User B: join with the shared key
24
+ envsync join myproject /path/to/.env <key>
25
+
26
+ # Both: start syncing over LAN
27
+ envsync sync myproject
28
+ ```
29
+
30
+ Changes to either `.env` file now sync automatically over LAN multicast.
31
+
32
+ ## Vault-Only Mode
33
+
34
+ No `.env` file ever touches disk — values live only in the encrypted vault.
35
+
36
+ ```
37
+ envsync create myproject
38
+ envsync set myproject DB_HOST localhost
39
+ envsync run myproject -- npm start
40
+ envsync export myproject
41
+ ```
42
+
43
+ `run` injects values into a subprocess's environment; `export` prints `export KEY=VALUE` lines for shell/direnv eval (`--format=env` for a plain `KEY=VALUE` `.env`-style file).
44
+
45
+ ## CLI Reference
46
+
47
+ ### Room management
48
+
49
+ | Command | Description |
50
+ |---|---|
51
+ | `envsync create <name> [file]` | Create a room; omit `file` for vault-only (no plaintext file ever) |
52
+ | `envsync join <name> <file> <key>` | Join an existing room with a shared key |
53
+ | `envsync init <name>` | Write `.envsync.yml` in this dir (metadata only, no secrets) |
54
+ | `envsync status` | List all rooms and whether they have unsynced local edits |
55
+
56
+ ### Syncing
57
+
58
+ | Command | Description |
59
+ |---|---|
60
+ | `envsync sync [name]` | Watch + LAN P2P sync with other peers |
61
+ | `envsync watch [name]` | Local-only: watch + encrypted history, no networking |
62
+ | `envsync daemon` | Headless: sync every locally known room in one process |
63
+ | `envsync daemon install\|uninstall\|status` | Manage the macOS launchd agent for `daemon` |
64
+
65
+ ### Values
66
+
67
+ | Command | Description |
68
+ |---|---|
69
+ | `envsync set [name] <key> <value>` | Set a value directly in the vault, no file needed |
70
+ | `envsync unset [name] <key>` | Remove a value directly from the vault |
71
+ | `envsync review [name] [--reveal]` | Show the last change, values masked by default |
72
+ | `envsync preview [name] [--reveal]` | Show every current key, values masked by default |
73
+ | `envsync history [name]` | Print decrypted change history |
74
+
75
+ ### Runtime injection
76
+
77
+ | Command | Description |
78
+ |---|---|
79
+ | `envsync run [name] -- <cmd> [args]` | Run a command with room values injected into its env (never written to disk) |
80
+ | `envsync export [name] [--format=env]` | Print `export KEY=VALUE` lines, or plain `KEY=VALUE` with `--format=env` |
81
+
82
+ ### Identity
83
+
84
+ | Command | Description |
85
+ |---|---|
86
+ | `envsync identity` | Print this device's alias and public key |
87
+ | `envsync alias [new-name]` | Print or set this device's display name |
88
+
89
+ ### Sharing
90
+
91
+ | Command | Description |
92
+ |---|---|
93
+ | `envsync invite [name]` | Reprint the QR code / join command for an existing room |
94
+ | `envsync invite-device <name> <pubkey>` | Wrap the room key for one device's public key, safe to paste anywhere |
95
+ | `envsync accept <name> <file> <env>` | Unwrap a device-targeted envelope from `invite-device` and join |
96
+
97
+ ### GitHub backup
98
+
99
+ | Command | Description |
100
+ |---|---|
101
+ | `envsync connect-github [repo-name]` | Sign in to GitHub once, create/reuse one shared private repo, push every room |
102
+ | `envsync backup-init <repo-url>` | Point the shared backup at an existing private repo you already created |
103
+ | `envsync backup [name]` | Push one room into the shared GitHub backup |
104
+ | `envsync restore [name] [repo-url]` | Pull a room from the shared GitHub backup (needs the room key locally already) |
105
+ | `envsync invite-github <username>` | Grant a teammate collaborator access to the shared private backup repo |
106
+ | `envsync revoke-github <username>` | Remove a collaborator from the shared private backup repo |
107
+
108
+ ### Key management
109
+
110
+ | Command | Description |
111
+ |---|---|
112
+ | `envsync rotate [name]` | Generate a new encryption key for this room and append to history |
113
+
114
+ `name` is optional wherever a `.envsync.yml` exists in or above the current directory.
115
+
116
+ ## GitHub Backup & Relay
117
+
118
+ ```
119
+ envsync connect-github myteam-envsync
120
+ ```
121
+
122
+ This authenticates via the `gh` CLI and creates (or reuses) one private repo shared across all your rooms. Every sync merge auto-pushes to it, debounced 3s after the merge with automatic retry every 60s. When peers aren't on the same LAN, the same repo doubles as an async relay: each peer pulls from it every ~45s and merges through the same last-write-wins logic, so it never blind-overwrites local state.
123
+
124
+ Invite a collaborator so they can push/pull the shared backup:
125
+
126
+ ```
127
+ envsync invite-github <username>
128
+ ```
129
+
130
+ ## Peer Discovery Layers
131
+
132
+ Peers are found through four layers, roughly fastest-to-slowest:
133
+
134
+ 1. **LAN multicast** — UDP multicast (`239.255.42.99:41234`, every 3s), instant, on by default.
135
+ 2. **Tailscale/ZeroTier mesh** — detected automatically, bypasses AP isolation on networks that block multicast.
136
+ 3. **GitHub gist signaling** — works across any network for peer handshake.
137
+ 4. **GitHub git relay** — async fallback, ~45s pull interval, for peers with no direct path to each other.
138
+
139
+ ## VS Code Extension
140
+
141
+ Install the `.vsix` from the `vscode-extension/` directory (`code --install-extension envsync-*.vsix`).
142
+
143
+ - Shield icon on a `.env` file with no `.envsync.yml` room — vault it.
144
+ - Check icon — review pending changes (masked diff).
145
+ - Status bar — shows sync state, room name, and last-change time for the workspace's tracked file.
146
+
147
+ ## Tray App (Electron)
148
+
149
+ ```
150
+ npm run tray
151
+ # or
152
+ npx electron .
153
+ ```
154
+
155
+ Menu covers: create/join rooms, sync now/stop per room, preview (masked/unmasked), review last change, and GitHub backup (connect, invite collaborator, connected-repo status). Auto-refreshes every 5s.
156
+
157
+ ## Auto-Start Daemon (macOS)
158
+
159
+ ```
160
+ envsync daemon install
161
+ envsync daemon status
162
+ envsync daemon uninstall
163
+ ```
164
+
165
+ Installs a launchd agent that runs `envsync daemon` on login, syncing every locally known room in one process. Logs to `~/.envsync/daemon.log`.
166
+
167
+ ## Project Config (`.envsync.yml`)
168
+
169
+ Auto-created by `envsync init <name>` in the current directory. Maps that directory to a room name so `envsync run`, `sync`, and the VS Code extension can find the right room without passing `name` explicitly.
170
+
171
+ ## Security Model
172
+
173
+ - AES-256-GCM encryption at rest (merged state, history log, backup payloads) and in transit (TCP sync).
174
+ - X25519 per-device keypairs for envelope encryption — a room key can be granted to one device via `invite-device`/`accept` without a shared-secret channel.
175
+ - Room keys are never sent in plaintext; LAN discovery advertises rooms via HMAC-SHA256(room key), and the TCP handshake is a challenge-response mutual auth over the same HMAC.
176
+ - GitHub sees only encrypted blobs — the backup repo holds ciphertext, never plaintext values.
177
+ - All sensitive files (`~/.envsync/identity.json`, room state, history) are `chmod 0600`.
178
+
179
+ ## Development
180
+
181
+ ```
182
+ npm test
183
+ npm run tray
184
+ ```
185
+
186
+ Project structure:
187
+
188
+ | File | Purpose |
189
+ |---|---|
190
+ | `cli.js` | CLI entry point and command dispatch |
191
+ | `lib.js` | Core room/state/encryption logic |
192
+ | `net.js` | LAN multicast discovery + TCP P2P sync |
193
+ | `mesh.js` | Tailscale/ZeroTier mesh detection |
194
+ | `signal.js` | GitHub gist peer signaling |
195
+ | `backup.js` | GitHub backup/relay (push/pull/invite) |
196
+ | `notify.js` | Cross-platform OS notifications |
197
+ | `tray/` | Electron tray app |
198
+
199
+ ## License
200
+
201
+ MIT
package/backup.js ADDED
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ // Backup/restore ALL rooms' encrypted vaults (merged.json + history.jsonl --
4
+ // both already AES-256-GCM encrypted, never plaintext) to ONE shared private
5
+ // GitHub repo, one subfolder per room. Connect once; every room's `sync`
6
+ // pushes into its own subfolder of the same repo. Plain `git`/`gh` shelled
7
+ // out to -- no GitHub API client library, no server of our own to run.
8
+ // This is a backup channel, not the sync transport: LAN P2P (net.js) stays
9
+ // the primary, always-on path; this is what recovers a room if no LAN peer
10
+ // is ever reachable, or a device is lost.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execFileSync } = require('child_process');
15
+ const lib = require('./lib');
16
+
17
+ function sharedDir() {
18
+ return path.join(path.dirname(lib.ROOT), 'backup');
19
+ }
20
+
21
+ function backupConfigFile() {
22
+ return path.join(path.dirname(lib.ROOT), 'backup-config.json');
23
+ }
24
+
25
+ function loadBackupConfig() {
26
+ const file = backupConfigFile();
27
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
28
+ }
29
+
30
+ function saveBackupConfig(config) {
31
+ const file = backupConfigFile();
32
+ fs.writeFileSync(file, JSON.stringify(config, null, 2));
33
+ fs.chmodSync(file, 0o600);
34
+ }
35
+
36
+ function isConnected() {
37
+ return loadBackupConfig() !== null;
38
+ }
39
+
40
+ function git(args) {
41
+ return execFileSync('git', args, { cwd: sharedDir(), stdio: ['ignore', 'pipe', 'pipe'] }).toString();
42
+ }
43
+
44
+ // One-time setup: point the shared backup repo at a GitHub URL.
45
+ function connectRepo(repoUrl) {
46
+ const dir = sharedDir();
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ if (!fs.existsSync(path.join(dir, '.git'))) {
49
+ git(['init']);
50
+ git(['checkout', '-b', 'main']);
51
+ }
52
+ const remotes = git(['remote']).split('\n').filter(Boolean);
53
+ if (remotes.includes('origin')) git(['remote', 'set-url', 'origin', repoUrl]);
54
+ else git(['remote', 'add', 'origin', repoUrl]);
55
+ saveBackupConfig({ repoUrl });
56
+ }
57
+
58
+ function stageRoom(name) {
59
+ const dir = path.join(sharedDir(), name);
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ const roomFile = path.join(lib.roomDir(name), 'merged.json');
62
+ const historyFile = path.join(lib.roomDir(name), 'history.jsonl');
63
+ if (fs.existsSync(roomFile)) fs.copyFileSync(roomFile, path.join(dir, 'merged.json'));
64
+ if (fs.existsSync(historyFile)) fs.copyFileSync(historyFile, path.join(dir, 'history.jsonl'));
65
+ }
66
+
67
+ // Best-effort push: called opportunistically (after a merge, or on a timer)
68
+ // while `sync` is running. Silently does nothing if not connected yet or
69
+ // there's no internet -- this is what "keeps in sync with the cloud
70
+ // whenever connected" means in practice: retry next time, don't block.
71
+ function pushBackup(name) {
72
+ if (!isConnected()) return { ok: false, reason: 'not connected -- run "envsync connect-github"' };
73
+ stageRoom(name);
74
+ try {
75
+ git(['add', name]);
76
+ if (git(['status', '--porcelain']).trim()) {
77
+ git(['commit', '-m', `envsync backup: ${name} ${new Date().toISOString()}`]);
78
+ }
79
+ try {
80
+ git(['push', '-u', 'origin', 'main']);
81
+ } catch {
82
+ // another room/device pushed in between -- rebase once and retry
83
+ git(['pull', '--rebase', 'origin', 'main']);
84
+ git(['push', '-u', 'origin', 'main']);
85
+ }
86
+ return { ok: true };
87
+ } catch (err) {
88
+ return { ok: false, reason: (err.stderr || err.message).toString().trim().split('\n').pop() };
89
+ }
90
+ }
91
+
92
+ // Phase 5: read-only fetch for the async relay in net.js -- pulls the
93
+ // shared repo and decrypts the room's remote merged state WITHOUT
94
+ // touching local room files. The caller merges this through the same
95
+ // last-write-wins logic used for LAN peers (applyMergedUpdate), so a
96
+ // stale or divergent remote can never blindly clobber local state.
97
+ function fetchRemoteMerged(name) {
98
+ if (!isConnected()) return { ok: false, reason: 'not connected' };
99
+ try {
100
+ git(['pull', '--rebase', 'origin', 'main']);
101
+ } catch (err) {
102
+ return { ok: false, reason: (err.stderr || err.message).toString().trim().split('\n').pop() };
103
+ }
104
+ const file = path.join(sharedDir(), name, 'merged.json');
105
+ if (!fs.existsSync(file)) return { ok: true, merged: {} };
106
+ try {
107
+ const config = lib.loadConfig(name);
108
+ const encrypted = JSON.parse(fs.readFileSync(file, 'utf8'));
109
+ return { ok: true, merged: lib.decrypt(config.key, encrypted) };
110
+ } catch (err) {
111
+ return { ok: false, reason: err.message };
112
+ }
113
+ }
114
+
115
+ function pullBackup(name) {
116
+ if (!isConnected()) return { ok: false, reason: 'not connected -- run "envsync connect-github"' };
117
+ try {
118
+ git(['pull', '--rebase', 'origin', 'main']);
119
+ } catch (err) {
120
+ return { ok: false, reason: (err.stderr || err.message).toString().trim().split('\n').pop() };
121
+ }
122
+ const dir = path.join(sharedDir(), name);
123
+ const roomFile = path.join(lib.roomDir(name), 'merged.json');
124
+ const historyFile = path.join(lib.roomDir(name), 'history.jsonl');
125
+ if (fs.existsSync(path.join(dir, 'merged.json'))) fs.copyFileSync(path.join(dir, 'merged.json'), roomFile);
126
+ if (fs.existsSync(path.join(dir, 'history.jsonl'))) fs.copyFileSync(path.join(dir, 'history.jsonl'), historyFile);
127
+ return { ok: true };
128
+ }
129
+
130
+ // --- GitHub connection, via the `gh` CLI (browser OAuth is `gh`'s job,
131
+ // not ours -- no reason to reimplement it when the CLI already does it
132
+ // well and is likely already installed on a dev machine). ---
133
+
134
+ function isGhInstalled() {
135
+ try { execFileSync('gh', ['--version']); return true; } catch { return false; }
136
+ }
137
+
138
+ function isGhAuthenticated() {
139
+ try { execFileSync('gh', ['auth', 'status']); return true; } catch { return false; }
140
+ }
141
+
142
+ function githubLogin() {
143
+ return execFileSync('gh', ['api', 'user', '--jq', '.login']).toString().trim();
144
+ }
145
+
146
+ // Blocks until the user finishes the browser flow -- fine for a CLI
147
+ // (it has a controlling terminal). The tray can't block this way; see
148
+ // tray/main.js for how it opens a real Terminal window instead.
149
+ function loginInteractive() {
150
+ execFileSync('gh', ['auth', 'login', '--web', '--git-protocol', 'https'], { stdio: 'inherit' });
151
+ }
152
+
153
+ // Parses "https://github.com/<owner>/<repo>.git" -- the only shape
154
+ // connectRepo/createGithubRepo ever produce or accept.
155
+ function parseRepoUrl(url) {
156
+ const match = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
157
+ if (!match) throw new Error(`Not a recognizable GitHub URL: ${url}`);
158
+ return { owner: match[1], repo: match[2] };
159
+ }
160
+
161
+ // The repo is kept private, so a new peer's own GitHub account needs
162
+ // collaborator access before their `sync` can push/pull the shared
163
+ // backup -- only the repo's owner/admin can grant that. GitHub sends the
164
+ // invitee a real invite they still have to accept themselves; this just
165
+ // starts that process via `gh api` instead of the web UI.
166
+ function addCollaborator(username) {
167
+ const config = loadBackupConfig();
168
+ if (!config) throw new Error('not connected -- run "envsync connect-github" first');
169
+ const { owner, repo } = parseRepoUrl(config.repoUrl);
170
+ execFileSync('gh', ['api', `repos/${owner}/${repo}/collaborators/${username}`, '-X', 'PUT']);
171
+ }
172
+
173
+ function removeCollaborator(username) {
174
+ const config = loadBackupConfig();
175
+ if (!config) throw new Error('not connected -- run "envsync connect-github" first');
176
+ const { owner, repo } = parseRepoUrl(config.repoUrl);
177
+
178
+ try {
179
+ execFileSync('gh', ['api', `repos/${owner}/${repo}/collaborators/${username}`, '-X', 'DELETE']);
180
+ } catch {
181
+ // Not an accepted collaborator; check for pending invitation instead
182
+ }
183
+
184
+ try {
185
+ const invitationsJson = execFileSync('gh', ['api', `repos/${owner}/${repo}/invitations`]).toString();
186
+ const invitations = JSON.parse(invitationsJson);
187
+ const invitation = invitations.find(inv => inv.invitee.login.toLowerCase() === username.toLowerCase());
188
+ if (invitation) {
189
+ execFileSync('gh', ['api', `repos/${owner}/${repo}/invitations/${invitation.id}`, '-X', 'DELETE']);
190
+ }
191
+ } catch {
192
+ // Silently ignore if invitations endpoint fails or no invitation found
193
+ }
194
+ }
195
+
196
+ function createGithubRepo(repoName) {
197
+ try {
198
+ execFileSync('gh', ['repo', 'create', repoName, '--private']);
199
+ } catch (err) {
200
+ const message = (err.stderr || err.message).toString();
201
+ if (!/already exists/i.test(message)) throw err;
202
+ }
203
+ return `https://github.com/${githubLogin()}/${repoName}.git`;
204
+ }
205
+
206
+ const DEFAULT_REPO_NAME = 'envsync-vault';
207
+
208
+ module.exports = {
209
+ sharedDir, loadBackupConfig, isConnected, connectRepo, pushBackup, pullBackup, fetchRemoteMerged,
210
+ isGhInstalled, isGhAuthenticated, githubLogin, loginInteractive, createGithubRepo, addCollaborator, removeCollaborator, DEFAULT_REPO_NAME,
211
+ };