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/cli.js ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const crypto = require('crypto');
8
+ const { execFileSync } = require('child_process');
9
+ const qrcode = require('qrcode-terminal');
10
+ const lib = require('./lib');
11
+ const net = require('./net');
12
+ const backup = require('./backup');
13
+
14
+ function printInviteQr(name, filePath, key) {
15
+ const inviteCommand = `envsync join ${name} <local-path-to-file> ${key}`;
16
+ qrcode.generate(inviteCommand, { small: true }, (qr) => console.log(qr));
17
+ console.log(`Or share this command directly: ${inviteCommand}`);
18
+ }
19
+
20
+ function promptDeleteOriginal(filePath) {
21
+ const readline = require('readline');
22
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
23
+ rl.question(
24
+ `Delete the original plaintext file at ${filePath}? It's now safely stored in the encrypted vault, so it doesn't need to sit in the repo. [y/N] `,
25
+ (answer) => {
26
+ rl.close();
27
+ if (/^y(es)?$/i.test(answer.trim())) {
28
+ fs.unlinkSync(filePath);
29
+ console.log('Deleted. Values now live only in the encrypted vault -- use "envsync run"/"export" to inject them, "envsync set"/"unset" to edit.');
30
+ } else {
31
+ console.log('Kept the file (still gitignored, so it won\'t get committed by accident).');
32
+ }
33
+ },
34
+ );
35
+ }
36
+
37
+ // promptDelete: false when called programmatically (VS Code extension, tray) --
38
+ // those have their own native confirmation dialogs instead of a stdin prompt.
39
+ function cmdCreate(name, filePath, { promptDelete = true } = {}) {
40
+ if (!name) throw new Error('usage: envsync create <name> [file] (omit file for a vault-only room -- no plaintext ever written to disk)');
41
+ fs.mkdirSync(lib.roomDir(name), { recursive: true });
42
+ if (fs.existsSync(lib.configFile(name))) throw new Error(`Room "${name}" already exists.`);
43
+ const resolvedPath = filePath ? require('path').resolve(filePath) : null;
44
+ const config = { name, filePath: resolvedPath, key: crypto.randomBytes(32).toString('hex'), peerId: lib.getDeviceIdentity().publicKey };
45
+ lib.saveConfig(name, config);
46
+ const initial = resolvedPath && fs.existsSync(resolvedPath) ? lib.parseEnv(fs.readFileSync(resolvedPath, 'utf8')) : {};
47
+ lib.appendHistory(name, { ts: Date.now(), values: initial, diff: lib.diffValues({}, initial) });
48
+ const merged = {};
49
+ const ts = Date.now();
50
+ for (const [k, v] of Object.entries(initial)) merged[k] = { value: v, ts, peer: config.peerId };
51
+ lib.saveMerged(name, merged);
52
+ if (resolvedPath) {
53
+ lib.ensureGitignored(resolvedPath);
54
+ console.log(`Room "${name}" created, tracking ${resolvedPath} (added to .gitignore)`);
55
+ } else {
56
+ console.log(`Room "${name}" created as vault-only -- no file on disk. Use "envsync set/unset" to edit, "envsync run -- <cmd>" to inject.`);
57
+ }
58
+ printInviteQr(name, resolvedPath, config.key);
59
+ if (resolvedPath && promptDelete) promptDeleteOriginal(resolvedPath);
60
+ }
61
+
62
+ function cmdSet(name, key, value) {
63
+ if (!key || value === undefined) throw new Error('usage: envsync set [name] <key> <value>');
64
+ lib.setValue(name, key, value);
65
+ console.log(`${key} set.`);
66
+ }
67
+
68
+ function cmdUnset(name, key) {
69
+ if (!key) throw new Error('usage: envsync unset [name] <key>');
70
+ lib.unsetValue(name, key);
71
+ console.log(`${key} unset.`);
72
+ }
73
+
74
+ function cmdJoin(name, filePath, keyHex) {
75
+ if (!name || !filePath || !keyHex) throw new Error('usage: envsync join <name> <file> <key>');
76
+ fs.mkdirSync(lib.roomDir(name), { recursive: true });
77
+ if (fs.existsSync(lib.configFile(name))) throw new Error(`Room "${name}" already exists locally.`);
78
+ const config = { name, filePath: require('path').resolve(filePath), key: keyHex, peerId: lib.getDeviceIdentity().publicKey };
79
+ lib.saveConfig(name, config);
80
+ lib.saveMerged(name, {});
81
+ lib.ensureGitignored(config.filePath);
82
+ if (!fs.existsSync(config.filePath)) fs.writeFileSync(config.filePath, '');
83
+ console.log(`Joined room "${name}", tracking ${config.filePath}. Run "envsync sync ${name}" to start syncing.`);
84
+ }
85
+
86
+ function cmdWatch(name) {
87
+ const config = lib.loadConfig(name);
88
+ console.log(`Watching ${config.filePath} for room "${name}" (ctrl-c to stop)`);
89
+ let prev = lib.readHistory(name).slice(-1)[0]?.values || {};
90
+ fs.watchFile(config.filePath, { interval: 1000 }, () => {
91
+ if (!fs.existsSync(config.filePath)) return;
92
+ const next = lib.parseEnv(fs.readFileSync(config.filePath, 'utf8'));
93
+ const diff = lib.diffValues(prev, next);
94
+ if (Object.keys(diff).length === 0) return;
95
+ lib.appendHistory(name, { ts: Date.now(), values: next, diff });
96
+ prev = next;
97
+ console.log(`[${new Date().toISOString()}] change detected:`, diff);
98
+ });
99
+ }
100
+
101
+ function cmdHistory(name) {
102
+ for (const entry of lib.readHistory(name)) {
103
+ console.log(`--- ${new Date(entry.ts).toISOString()}${entry.source ? ` (from ${lib.peerLabel(name, entry.source)})` : ''} ---`);
104
+ if (entry.reveal) {
105
+ console.log(` (reveal: ${entry.reveal.join(', ')})`);
106
+ } else if (Object.keys(entry.diff).length === 0) {
107
+ console.log(' (initial snapshot)');
108
+ } else {
109
+ for (const [key, change] of Object.entries(entry.diff)) {
110
+ console.log(` ${key}: ${JSON.stringify(change)}`);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ function cmdSync(name) {
117
+ net.startSync(name);
118
+ }
119
+
120
+ function cmdDaemon() {
121
+ net.startAllRooms();
122
+ }
123
+
124
+ const PLIST_PATH = path.join(os.homedir(), 'Library/LaunchAgents/com.envsync.daemon.plist');
125
+ const DAEMON_LOG_PATH = path.join(os.homedir(), '.envsync/daemon.log');
126
+
127
+ function daemonPlist() {
128
+ const cliPath = path.resolve(__dirname, 'cli.js');
129
+ return `<?xml version="1.0" encoding="UTF-8"?>
130
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
131
+ <plist version="1.0">
132
+ <dict>
133
+ <key>Label</key>
134
+ <string>com.envsync.daemon</string>
135
+ <key>ProgramArguments</key>
136
+ <array>
137
+ <string>${process.execPath}</string>
138
+ <string>${cliPath}</string>
139
+ <string>daemon</string>
140
+ </array>
141
+ <key>RunAtLoad</key>
142
+ <true/>
143
+ <key>KeepAlive</key>
144
+ <true/>
145
+ <key>StandardOutPath</key>
146
+ <string>${DAEMON_LOG_PATH}</string>
147
+ <key>StandardErrorPath</key>
148
+ <string>${DAEMON_LOG_PATH}</string>
149
+ <key>WorkingDirectory</key>
150
+ <string>${path.dirname(cliPath)}</string>
151
+ </dict>
152
+ </plist>
153
+ `;
154
+ }
155
+
156
+ // ponytail: macOS-only (launchd) -- Linux/Windows auto-start (systemd/Task
157
+ // Scheduler) is out of scope, "envsync daemon" itself still runs anywhere.
158
+ function cmdDaemonInstall() {
159
+ if (process.platform !== 'darwin') {
160
+ console.log('launchd auto-start is macOS-only. Run "envsync daemon" directly, or wire it up with your OS\'s own equivalent (cron/systemd/Task Scheduler).');
161
+ return;
162
+ }
163
+ fs.mkdirSync(path.dirname(DAEMON_LOG_PATH), { recursive: true });
164
+ fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
165
+ fs.writeFileSync(PLIST_PATH, daemonPlist());
166
+ try {
167
+ execFileSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, PLIST_PATH]);
168
+ } catch {
169
+ execFileSync('launchctl', ['load', '-w', PLIST_PATH]);
170
+ }
171
+ console.log(`Installed and started com.envsync.daemon. Logs: ${DAEMON_LOG_PATH}`);
172
+ }
173
+
174
+ function cmdDaemonUninstall() {
175
+ if (process.platform !== 'darwin') {
176
+ console.log('launchd auto-start is macOS-only. Nothing to uninstall here.');
177
+ return;
178
+ }
179
+ try {
180
+ execFileSync('launchctl', ['bootout', `gui/${process.getuid()}/com.envsync.daemon`]);
181
+ } catch {
182
+ try { execFileSync('launchctl', ['unload', PLIST_PATH]); } catch { /* not loaded */ }
183
+ }
184
+ if (fs.existsSync(PLIST_PATH)) fs.unlinkSync(PLIST_PATH);
185
+ console.log('Uninstalled com.envsync.daemon.');
186
+ }
187
+
188
+ function cmdDaemonStatus() {
189
+ if (process.platform !== 'darwin') {
190
+ console.log('launchd auto-start is macOS-only. Run "envsync daemon" directly, or wire it up with your OS\'s own equivalent (cron/systemd/Task Scheduler).');
191
+ return;
192
+ }
193
+ if (!fs.existsSync(PLIST_PATH)) { console.log('Not installed. Run "envsync daemon install".'); return; }
194
+ const list = execFileSync('launchctl', ['list']).toString('utf8');
195
+ const installed = list.includes('com.envsync.daemon');
196
+ console.log(installed ? `Installed and running. Logs: ${DAEMON_LOG_PATH}` : `Installed but not running. Logs: ${DAEMON_LOG_PATH}`);
197
+ }
198
+
199
+ function cmdInit(name) {
200
+ if (!name) throw new Error('usage: envsync init <name> (run inside the project after create/join)');
201
+ const config = lib.loadConfig(name);
202
+ lib.writeProjectConfig(process.cwd(), { room: name, file: config.filePath });
203
+ console.log(`Wrote .envsync.yml (room "${name}" -> ${config.filePath}). Other commands in this directory no longer need a name.`);
204
+ }
205
+
206
+ function cmdStatus() {
207
+ const statuses = lib.listRoomStatuses();
208
+ if (!statuses.length) { console.log('No rooms yet. Run: envsync create <name> <file>'); return; }
209
+ for (const { name, filePath, pending, lastTs } of statuses) {
210
+ const lastTsStr = lastTs ? new Date(lastTs).toISOString() : 'never';
211
+ console.log(`${pending ? '\u{1F7E1}' : '\u{1F7E2}'} ${name} ${filePath} last change: ${lastTsStr}${pending ? ' (local edits not yet synced -- run "envsync sync ' + name + '")' : ''}`);
212
+ }
213
+ }
214
+
215
+ function cmdInvite(name) {
216
+ const config = lib.loadConfig(name);
217
+ printInviteQr(name, config.filePath, config.key);
218
+ }
219
+
220
+ function cmdIdentity() {
221
+ const identity = lib.getDeviceIdentity();
222
+ console.log(`Alias: ${identity.label}`);
223
+ console.log(`Public key (safe to share):\n${identity.publicKey}`);
224
+ }
225
+
226
+ function cmdAlias(newLabel) {
227
+ if (!newLabel) { console.log(lib.getDeviceIdentity().label); return; }
228
+ lib.setDeviceAlias(newLabel);
229
+ console.log(`Alias set to "${newLabel}". Other peers will see this on your next sync.`);
230
+ }
231
+
232
+ function cmdInviteDevice(name, recipientPublicKey) {
233
+ if (!recipientPublicKey) throw new Error('usage: envsync invite-device <name> <recipient-public-key> (get theirs via "envsync identity" on their machine)');
234
+ const config = lib.loadConfig(name);
235
+ const envelope = lib.wrapRoomKey(config.key, recipientPublicKey);
236
+ const encoded = Buffer.from(JSON.stringify(envelope)).toString('base64');
237
+ console.log(`Room key encrypted for that device only -- safe to paste anywhere (Slack, cloud backup, etc), only their private key can open it:\n`);
238
+ console.log(`envsync accept ${name} <local-path-to-file> ${encoded}`);
239
+ }
240
+
241
+ function cmdAccept(name, filePath, encodedEnvelope) {
242
+ if (!name || !filePath || !encodedEnvelope) throw new Error('usage: envsync accept <name> <file> <envelope>');
243
+ const envelope = JSON.parse(Buffer.from(encodedEnvelope, 'base64').toString('utf8'));
244
+ const keyHex = lib.unwrapRoomKey(envelope);
245
+ cmdJoin(name, filePath, keyHex);
246
+ }
247
+
248
+ function cmdBackupInit(githubRepoUrl) {
249
+ if (!githubRepoUrl) throw new Error('usage: envsync backup-init <github-repo-url> (point at an existing private repo you already created)');
250
+ backup.connectRepo(githubRepoUrl);
251
+ console.log(`Connected to ${githubRepoUrl}. Run "envsync backup [name]" or let "envsync sync" push automatically.`);
252
+ }
253
+
254
+ function cmdBackup(name) {
255
+ const result = backup.pushBackup(name);
256
+ console.log(result.ok ? `Pushed "${name}" to GitHub backup.` : `Backup push failed: ${result.reason}`);
257
+ }
258
+
259
+ function cmdConnectGithub(repoName) {
260
+ if (!backup.isGhInstalled()) {
261
+ throw new Error('GitHub CLI ("gh") not found. Install it first: https://cli.github.com');
262
+ }
263
+ if (!backup.isGhAuthenticated()) {
264
+ console.log('Not signed in to GitHub -- opening browser sign-in (gh auth login)...');
265
+ backup.loginInteractive();
266
+ }
267
+ const finalRepoName = repoName || backup.DEFAULT_REPO_NAME;
268
+ console.log(`Creating (or reusing) private GitHub repo "${finalRepoName}"...`);
269
+ const url = backup.createGithubRepo(finalRepoName);
270
+ backup.connectRepo(url);
271
+ const rooms = lib.listRoomStatuses();
272
+ const results = rooms.map(({ name }) => ({ name, result: backup.pushBackup(name) }));
273
+ console.log(`Connected to ${url}.`);
274
+ for (const { name, result } of results) {
275
+ console.log(result.ok ? ` pushed "${name}"` : ` "${name}" push failed: ${result.reason}`);
276
+ }
277
+ }
278
+
279
+ function cmdInviteGithub(username) {
280
+ if (!username) throw new Error('usage: envsync invite-github <github-username> (grants them collaborator access to the shared private backup repo)');
281
+ backup.addCollaborator(username);
282
+ console.log(`Invited "${username}" as a collaborator on the shared backup repo.`);
283
+ console.log(`They must accept the invite (github.com notifications or their email), then run:\n envsync backup-init <the-repo-url>`);
284
+ }
285
+
286
+ function cmdRevokeGithub(username) {
287
+ if (!username) throw new Error('usage: envsync revoke-github <github-username> (removes them from the shared private backup repo)');
288
+ backup.removeCollaborator(username);
289
+ console.log(`Removed "${username}" from the shared backup repo.`);
290
+ }
291
+
292
+ function cmdRestore(name, githubRepoUrl) {
293
+ if (githubRepoUrl) backup.connectRepo(githubRepoUrl);
294
+ else if (!backup.isConnected()) throw new Error('usage: envsync restore <name> [github-repo-url] (or run "envsync connect-github" / "backup-init" first)');
295
+ const result = backup.pullBackup(name);
296
+ if (result.ok) {
297
+ // pullBackup only updates the encrypted vault (merged.json/history.jsonl).
298
+ // Write the actual tracked file too, so a later "sync" doesn't see an
299
+ // empty on-disk file and mistake the restore for an intentional wipe.
300
+ const config = lib.loadConfig(name);
301
+ fs.writeFileSync(config.filePath, lib.serializeEnv(lib.currentValues(name)));
302
+ }
303
+ console.log(result.ok ? 'Restored encrypted vault from GitHub backup.' : `Restore failed: ${result.reason}`);
304
+ }
305
+
306
+ function cmdReview(name, reveal) {
307
+ const history = lib.readHistory(name);
308
+ if (!history.length) { console.log('No history yet.'); return; }
309
+ const last = history[history.length - 1];
310
+ console.log(`Last change: ${new Date(last.ts).toISOString()}${last.source ? ` (from ${lib.peerLabel(name, last.source)})` : ''}`);
311
+ for (const [key, change] of Object.entries(last.diff)) {
312
+ if (change.type === 'removed') { console.log(` ${key}: removed`); continue; }
313
+ const value = change.type === 'changed' ? change.to : change.value;
314
+ console.log(` ${key}: ${reveal ? value : lib.mask(String(value))}`);
315
+ }
316
+ if (reveal) {
317
+ lib.appendHistory(name, { ts: Date.now(), values: last.values, diff: {}, reveal: Object.keys(last.diff) });
318
+ console.log('(reveal logged to history)');
319
+ } else {
320
+ console.log('(values masked -- pass --reveal to show them)');
321
+ }
322
+ }
323
+
324
+ function cmdRotate(name) {
325
+ const newKey = lib.rotateRoomKey(name);
326
+ console.log(`Room "${name}" key rotated. New encryption key:`);
327
+ console.log(newKey);
328
+ console.log('\nShare this key with every device that should sync this room via: envsync invite-device or envsync join');
329
+ console.log('Note: data already synced to devices before rotation is not retroactively protected.');
330
+ }
331
+
332
+ function cmdRun(name, commandArgs) {
333
+ if (!commandArgs.length) throw new Error('usage: envsync run [name] -- <command> [args...]');
334
+ const values = lib.currentValues(name);
335
+ const child = require('child_process').spawn(commandArgs[0], commandArgs.slice(1), {
336
+ stdio: 'inherit',
337
+ env: { ...process.env, ...values },
338
+ });
339
+ child.on('exit', (code) => process.exit(code ?? 0));
340
+ }
341
+
342
+ function cmdExport(name, format) {
343
+ const values = lib.currentValues(name);
344
+ for (const [k, v] of Object.entries(values)) {
345
+ const quoted = `"${String(v).replace(/(["\\$`])/g, '\\$1')}"`;
346
+ console.log(format === 'env' ? `${k}=${v}` : `export ${k}=${quoted}`);
347
+ }
348
+ }
349
+
350
+ function cmdPreview(name, reveal) {
351
+ const values = lib.currentValues(name);
352
+ const keys = Object.keys(values);
353
+ if (!keys.length) { console.log('(no values tracked yet)'); return; }
354
+ for (const key of keys) console.log(`${key}=${reveal ? values[key] : lib.mask(String(values[key]))}`);
355
+ if (reveal) {
356
+ lib.appendHistory(name, { ts: Date.now(), values, diff: {}, reveal: keys });
357
+ console.log('(reveal logged to history)');
358
+ } else {
359
+ console.log('(values masked -- pass --reveal to show them)');
360
+ }
361
+ }
362
+
363
+ function resolveName(argName) {
364
+ if (argName) return argName;
365
+ const projectConfig = lib.findProjectConfig(process.cwd());
366
+ if (projectConfig?.room) return projectConfig.room;
367
+ throw new Error('no room name given and no .envsync.yml found -- pass a name or run "envsync init <name>" first');
368
+ }
369
+
370
+ function main() {
371
+ const [, , cmd, ...rawArgs] = process.argv;
372
+
373
+ if (cmd === 'run') {
374
+ const sepIdx = rawArgs.indexOf('--');
375
+ if (sepIdx === -1) throw new Error('usage: envsync run [name] -- <command> [args...]');
376
+ const name = resolveName(rawArgs.slice(0, sepIdx)[0]);
377
+ cmdRun(name, rawArgs.slice(sepIdx + 1));
378
+ return;
379
+ }
380
+
381
+ const reveal = rawArgs.includes('--reveal');
382
+ const formatIdx = rawArgs.findIndex((a) => a.startsWith('--format='));
383
+ const format = formatIdx !== -1 ? rawArgs[formatIdx].split('=')[1] : 'shell';
384
+ const args = rawArgs.filter((a) => a !== '--reveal' && !a.startsWith('--format='));
385
+ try {
386
+ if (cmd === 'create') cmdCreate(args[0], args[1]);
387
+ else if (cmd === 'join') cmdJoin(args[0], args[1], args[2]);
388
+ else if (cmd === 'init') cmdInit(resolveName(args[0]));
389
+ else if (cmd === 'watch') cmdWatch(resolveName(args[0]));
390
+ else if (cmd === 'sync') cmdSync(resolveName(args[0]));
391
+ else if (cmd === 'daemon') {
392
+ if (args[0] === 'install') cmdDaemonInstall();
393
+ else if (args[0] === 'uninstall') cmdDaemonUninstall();
394
+ else if (args[0] === 'status') cmdDaemonStatus();
395
+ else cmdDaemon();
396
+ }
397
+ else if (cmd === 'history') cmdHistory(resolveName(args[0]));
398
+ else if (cmd === 'review') cmdReview(resolveName(args[0]), reveal);
399
+ else if (cmd === 'preview') cmdPreview(resolveName(args[0]), reveal);
400
+ else if (cmd === 'status') cmdStatus();
401
+ else if (cmd === 'set') {
402
+ // "set key value" (name from .envsync.yml) or "set name key value"
403
+ const [a, b, c] = args;
404
+ if (c !== undefined) cmdSet(a, b, c);
405
+ else cmdSet(resolveName(undefined), a, b);
406
+ }
407
+ else if (cmd === 'unset') {
408
+ // "unset key" (name from .envsync.yml) or "unset name key"
409
+ const [a, b] = args;
410
+ if (b !== undefined) cmdUnset(a, b);
411
+ else cmdUnset(resolveName(undefined), a);
412
+ }
413
+ else if (cmd === 'invite') cmdInvite(resolveName(args[0]));
414
+ else if (cmd === 'identity') cmdIdentity();
415
+ else if (cmd === 'alias') cmdAlias(args[0]);
416
+ else if (cmd === 'invite-device') cmdInviteDevice(args[0], args[1]);
417
+ else if (cmd === 'accept') cmdAccept(args[0], args[1], args[2]);
418
+ else if (cmd === 'rotate') cmdRotate(resolveName(args[0]));
419
+ else if (cmd === 'backup-init') cmdBackupInit(args[0]);
420
+ else if (cmd === 'connect-github') cmdConnectGithub(args[0]);
421
+ else if (cmd === 'invite-github') cmdInviteGithub(args[0]);
422
+ else if (cmd === 'revoke-github') cmdRevokeGithub(args[0]);
423
+ else if (cmd === 'backup') cmdBackup(resolveName(args[0]));
424
+ else if (cmd === 'restore') cmdRestore(resolveName(args[0]), args[1]);
425
+ else if (cmd === 'export') cmdExport(resolveName(args[0]), format);
426
+ else {
427
+ console.log([
428
+ 'usage:',
429
+ ' envsync create <name> [file] create a room; omit file for a vault-only room (no plaintext file ever)',
430
+ ' envsync join <name> <file> <key> join an existing room with a shared key',
431
+ ' envsync set [name] <key> <value> set a value directly in the vault -- no file needed',
432
+ ' envsync unset [name] <key> remove a value directly from the vault',
433
+ ' envsync init <name> write .envsync.yml in this dir (metadata only, no secrets)',
434
+ ' envsync sync [name] watch + LAN P2P sync with other peers',
435
+ ' envsync daemon headless: sync every locally known room in one process',
436
+ ' envsync daemon install (macOS) install + start a launchd agent that runs "envsync daemon" on login',
437
+ ' envsync daemon uninstall (macOS) stop and remove the launchd agent',
438
+ ' envsync daemon status (macOS) check whether the launchd agent is installed/running',
439
+ ' envsync watch [name] local-only: watch + encrypted history, no networking',
440
+ ' envsync review [name] [--reveal] show the last change, values masked by default',
441
+ ' envsync preview [name] [--reveal] show every current key, values masked by default',
442
+ ' envsync status list all rooms and whether they have unsynced local edits',
443
+ ' envsync invite [name] reprint the QR code / join command for an existing room',
444
+ ' envsync identity print this device\'s alias and public key (share the key so others can invite you)',
445
+ ' envsync alias [new-name] print or set this device\'s display name (what peers see in history/notifications)',
446
+ ' envsync invite-device <name> <pubkey> wrap the room key for one device\'s public key -- safe to paste anywhere',
447
+ ' envsync accept <name> <file> <env> unwrap a device-targeted envelope from invite-device and join',
448
+ ' envsync rotate [name] generate a new encryption key for this room and append to history',
449
+ ' envsync connect-github [repo-name] sign in to GitHub once, create/reuse ONE shared private repo, push every room',
450
+ ' envsync backup-init <repo-url> point the shared backup at an existing private repo you already created',
451
+ ' envsync invite-github <username> grant a teammate collaborator access to the shared private backup repo',
452
+ ' envsync revoke-github <username> remove a collaborator from the shared private backup repo',
453
+ ' envsync backup [name] push one room into the shared GitHub backup',
454
+ ' envsync restore [name] [repo-url] pull a room from the shared GitHub backup (needs the room key locally already)',
455
+ ' envsync run [name] -- <cmd> [args] run a command with room values injected into its env (never written to disk)',
456
+ ' envsync export [name] [--format=env] print `export KEY=VALUE` lines for shell/direnv eval, or plain KEY=VALUE with --format=env for --env-file',
457
+ ' envsync history [name] print decrypted change history',
458
+ '(name is optional wherever a .envsync.yml exists in or above the cwd)',
459
+ ].join('\n'));
460
+ }
461
+ } catch (err) {
462
+ console.error('Error:', err.message);
463
+ process.exit(1);
464
+ }
465
+ }
466
+
467
+ if (require.main === module) main();
468
+
469
+ module.exports = { cmdCreate };