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/tray/main.js ADDED
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ // Phase 3 tray app: a menu-bar icon showing room status, with per-room
4
+ // Sync/Review/Reveal actions, plus Create/Join Room. No renderer/window
5
+ // needed -- Tray + native Menu + dialog + osascript prompts cover it.
6
+
7
+ const { app, Tray, Menu, nativeImage, dialog } = require('electron');
8
+ const { spawn, execFileSync } = require('child_process');
9
+ const path = require('path');
10
+ const lib = require('../lib');
11
+ const backup = require('../backup');
12
+ const icons = require('./icons');
13
+ const { cmdCreate } = require('../cli');
14
+ const { promptText } = require('./prompt');
15
+
16
+ const CLI_PATH = path.join(__dirname, '..', 'cli.js');
17
+ const REFRESH_MS = 5000;
18
+
19
+ // macOS menu bar icons render at ~18x18 points; the source PNGs are 32x32
20
+ // so they get supersampled-then-downscaled here for a crisp result.
21
+ const TRAY_ICON_SIZE = { width: 18, height: 18 };
22
+ const ICONS = {
23
+ green: nativeImage.createFromDataURL(icons.green).resize(TRAY_ICON_SIZE),
24
+ yellow: nativeImage.createFromDataURL(icons.yellow).resize(TRAY_ICON_SIZE),
25
+ gray: nativeImage.createFromDataURL(icons.gray).resize(TRAY_ICON_SIZE),
26
+ };
27
+
28
+ const running = new Map(); // room name -> child process
29
+
30
+ function toggleSync(name) {
31
+ if (running.has(name)) {
32
+ running.get(name).kill();
33
+ running.delete(name);
34
+ return;
35
+ }
36
+ const child = spawn(process.execPath, [CLI_PATH, 'sync', name], { stdio: 'ignore' });
37
+ child.on('exit', () => running.delete(name));
38
+ running.set(name, child);
39
+ }
40
+
41
+ function runCli(args) {
42
+ try {
43
+ return { ok: true, output: execFileSync(process.execPath, [CLI_PATH, ...args]).toString() };
44
+ } catch (err) {
45
+ return { ok: false, output: (err.stderr || err.stdout || err.message).toString() };
46
+ }
47
+ }
48
+
49
+ function createRoomFlow(refresh) {
50
+ const name = promptText('Room name:');
51
+ if (!name) return;
52
+ const filePath = promptText('Full path to the .env file to track (leave blank for a vault-only room -- no file on disk, ever):');
53
+ // promptText returns null on Cancel, '' on an intentionally empty answer --
54
+ // only treat Cancel as an abort; '' means "vault-only, no file".
55
+ if (filePath === null) return;
56
+
57
+ // In-process, not shelled out (unlike other tray actions) -- cmdCreate can
58
+ // prompt interactively over stdin when run as a CLI, which would hang
59
+ // Electron's main process indefinitely if spawned via execFileSync here.
60
+ try {
61
+ cmdCreate(name, filePath || undefined, { promptDelete: false });
62
+ if (filePath) {
63
+ const deleteChoice = dialog.showMessageBoxSync({
64
+ type: 'warning',
65
+ buttons: ['Keep File', 'Delete File'],
66
+ defaultId: 0,
67
+ message: 'Delete the original plaintext file? It\'s now safely stored in the encrypted vault -- no need for it to sit in the repo.',
68
+ });
69
+ if (deleteChoice === 1) {
70
+ require('fs').unlinkSync(filePath);
71
+ dialog.showMessageBox({ message: 'Deleted. Values now live only in the encrypted vault.' });
72
+ }
73
+ } else {
74
+ dialog.showMessageBox({ title: 'Room created', message: `"${name}" created as vault-only -- no file on disk.` });
75
+ }
76
+ } catch (err) {
77
+ dialog.showMessageBox({ title: 'Failed to create room', message: err.message });
78
+ }
79
+ refresh();
80
+ }
81
+
82
+ function joinRoomFlow(refresh) {
83
+ const name = promptText('Room name to join:');
84
+ if (!name) return;
85
+ const folders = dialog.showOpenDialogSync({
86
+ title: 'Choose the folder for this project\'s .env file',
87
+ properties: ['openDirectory', 'createDirectory'],
88
+ });
89
+ if (!folders || folders.length === 0) return;
90
+ const chosenFolder = folders[0];
91
+ const filePath = path.join(chosenFolder, '.env');
92
+ const key = promptText('Room key (from whoever ran "create" or "invite"):');
93
+ if (!key) return;
94
+ const result = runCli(['join', name, filePath, key]);
95
+ dialog.showMessageBox({
96
+ title: result.ok ? 'Joined room' : 'Failed to join room',
97
+ message: result.output,
98
+ });
99
+ if (result.ok) {
100
+ lib.writeProjectConfig(chosenFolder, { room: name, file: filePath });
101
+ }
102
+ refresh();
103
+ }
104
+
105
+ function showDiff(name, reveal) {
106
+ const history = lib.readHistory(name);
107
+ if (!history.length) {
108
+ dialog.showMessageBox({ title: name, message: 'No history yet.' });
109
+ return;
110
+ }
111
+ const last = history[history.length - 1];
112
+ const lines = Object.entries(last.diff).map(([key, change]) => {
113
+ if (change.type === 'removed') return `${key}: removed`;
114
+ const value = change.type === 'changed' ? change.to : change.value;
115
+ return `${key}: ${reveal ? value : lib.mask(String(value))}`;
116
+ });
117
+ if (reveal) {
118
+ lib.appendHistory(name, { ts: Date.now(), values: last.values, diff: {}, reveal: Object.keys(last.diff) });
119
+ }
120
+ dialog.showMessageBox({
121
+ title: `${name} -- last change ${new Date(last.ts).toISOString()}`,
122
+ message: lines.join('\n') || '(no changes)',
123
+ });
124
+ }
125
+
126
+ function showPreview(name, reveal) {
127
+ const values = lib.currentValues(name);
128
+ const keys = Object.keys(values);
129
+ const lines = keys.map((key) => `${key}=${reveal ? values[key] : lib.mask(String(values[key]))}`);
130
+ if (reveal) {
131
+ lib.appendHistory(name, { ts: Date.now(), values, diff: {}, reveal: keys });
132
+ }
133
+ dialog.showMessageBox({
134
+ title: `${name} -- current values${reveal ? ' (revealed)' : ' (masked)'}`,
135
+ message: lines.join('\n') || '(no values tracked yet)',
136
+ buttons: reveal ? ['Close'] : ['Close', 'Unmask'],
137
+ }).then((res) => {
138
+ if (!reveal && res.response === 1) showPreview(name, true);
139
+ });
140
+ }
141
+
142
+ // Electron's main process has no controlling terminal, so `gh auth login`
143
+ // can't run inline here the way it can from the CLI -- open a real Terminal
144
+ // window for it instead (same osascript trick as promptText/notify), then
145
+ // let the user re-click once they've finished signing in.
146
+ // One shared repo for every room -- connect once, all rooms push into it.
147
+ function connectGithubFlow(refresh) {
148
+ if (!backup.isGhInstalled()) {
149
+ dialog.showMessageBox({ message: 'GitHub CLI ("gh") not found. Install it first: https://cli.github.com' });
150
+ return;
151
+ }
152
+ if (!backup.isGhAuthenticated()) {
153
+ if (process.platform === 'darwin') {
154
+ execFileSync('osascript', ['-e', 'tell application "Terminal" to do script "gh auth login --web"']);
155
+ dialog.showMessageBox({
156
+ title: 'Sign in to GitHub',
157
+ message: 'A Terminal window opened to complete GitHub sign-in.\nOnce finished, click "Connect GitHub Backup..." again.',
158
+ });
159
+ } else {
160
+ dialog.showMessageBox({ message: 'Not signed in to GitHub. Run "gh auth login" in a terminal, then try again.' });
161
+ }
162
+ return;
163
+ }
164
+ const repoName = promptText('Private GitHub repo name for the shared backup:', backup.DEFAULT_REPO_NAME);
165
+ if (!repoName) return;
166
+ try {
167
+ const url = backup.createGithubRepo(repoName);
168
+ backup.connectRepo(url);
169
+ const results = lib.listRoomStatuses().map(({ name }) => ({ name, result: backup.pushBackup(name) }));
170
+ const lines = results.map(({ name, result }) => `${name}: ${result.ok ? 'pushed' : result.reason}`);
171
+ dialog.showMessageBox({ title: `Connected to ${url}`, message: lines.join('\n') || '(no rooms yet)' });
172
+ } catch (err) {
173
+ dialog.showMessageBox({ title: 'Failed to connect GitHub backup', message: err.message });
174
+ }
175
+ refresh();
176
+ }
177
+
178
+ function inviteGithubCollaboratorFlow() {
179
+ if (!backup.isConnected()) {
180
+ dialog.showMessageBox({ message: 'Connect GitHub Backup first, then invite teammates as collaborators.' });
181
+ return;
182
+ }
183
+ const username = promptText('GitHub username to invite as a collaborator on the shared backup repo:');
184
+ if (!username) return;
185
+ try {
186
+ backup.addCollaborator(username);
187
+ dialog.showMessageBox({
188
+ title: 'Invited',
189
+ message: `Invited "${username}". They must accept the GitHub invite, then run:\nenvsync backup-init ${backup.loadBackupConfig().repoUrl}`,
190
+ });
191
+ } catch (err) {
192
+ dialog.showMessageBox({ title: 'Failed to invite collaborator', message: err.message });
193
+ }
194
+ }
195
+
196
+ function pushRoomToGithub(name) {
197
+ if (!backup.isConnected()) {
198
+ dialog.showMessageBox({ message: 'Not connected to GitHub yet -- use "Connect GitHub Backup..." first.' });
199
+ return;
200
+ }
201
+ const result = backup.pushBackup(name);
202
+ dialog.showMessageBox({
203
+ title: result.ok ? 'Pushed' : 'Push failed',
204
+ message: result.ok ? `"${name}" pushed to the shared GitHub backup.` : result.reason,
205
+ });
206
+ }
207
+
208
+ function buildMenu(tray) {
209
+ const statuses = lib.listRoomStatuses();
210
+ const anyPending = statuses.some((s) => s.pending);
211
+ tray.setImage(statuses.length === 0 ? ICONS.gray : anyPending ? ICONS.yellow : ICONS.green);
212
+ tray.setToolTip(statuses.length ? `${statuses.length} room(s) tracked` : 'No rooms yet');
213
+
214
+ const refresh = () => buildMenu(tray);
215
+ const connected = backup.isConnected();
216
+ const githubLabel = connected
217
+ ? `GitHub Backup: connected (${backup.loadBackupConfig().repoUrl})`
218
+ : 'GitHub Backup: not connected';
219
+ const myAlias = lib.getDeviceIdentity().label;
220
+ const template = [
221
+ { label: `This device: ${myAlias}`, enabled: false },
222
+ {
223
+ label: 'Edit Device Alias...',
224
+ click: () => {
225
+ const newAlias = promptText('Display name other peers will see:', myAlias);
226
+ if (newAlias) { lib.setDeviceAlias(newAlias); refresh(); }
227
+ },
228
+ },
229
+ { type: 'separator' },
230
+ { label: 'Create Room...', click: () => createRoomFlow(refresh) },
231
+ { label: 'Join Room...', click: () => joinRoomFlow(refresh) },
232
+ { type: 'separator' },
233
+ { label: githubLabel, enabled: false },
234
+ { label: connected ? 'Reconnect GitHub Backup...' : 'Connect GitHub Backup...', click: () => connectGithubFlow(refresh) },
235
+ { label: 'Invite GitHub Collaborator...', enabled: connected, click: () => inviteGithubCollaboratorFlow() },
236
+ { type: 'separator' },
237
+ ];
238
+
239
+ if (statuses.length) {
240
+ for (const { name, filePath, pending } of statuses) {
241
+ template.push({
242
+ label: `${pending ? '\u{1F7E1}' : '\u{1F7E2}'} ${name}`,
243
+ submenu: [
244
+ { label: filePath, enabled: false },
245
+ { type: 'separator' },
246
+ { label: running.has(name) ? 'Stop syncing' : 'Sync now', click: () => toggleSync(name) },
247
+ { label: 'Push to GitHub now', click: () => pushRoomToGithub(name) },
248
+ { label: 'Preview env (masked)', click: () => showPreview(name, false) },
249
+ { label: 'Review last change (masked)', click: () => showDiff(name, false) },
250
+ {
251
+ label: 'Reveal last change...',
252
+ click: () => {
253
+ dialog.showMessageBox({
254
+ type: 'question',
255
+ buttons: ['Cancel', 'Reveal'],
256
+ defaultId: 0,
257
+ message: `Reveal actual values for "${name}"? This is logged to history.`,
258
+ }).then((res) => { if (res.response === 1) showDiff(name, true); });
259
+ },
260
+ },
261
+ ],
262
+ });
263
+ }
264
+ } else {
265
+ template.push({ label: 'No rooms yet', enabled: false });
266
+ }
267
+
268
+ template.push({ type: 'separator' });
269
+ template.push({ label: 'Quit', click: () => { for (const child of running.values()) child.kill(); app.quit(); } });
270
+
271
+ tray.setContextMenu(Menu.buildFromTemplate(template));
272
+ }
273
+
274
+ app.whenReady().then(() => {
275
+ if (process.platform === 'darwin') app.dock?.hide();
276
+ const tray = new Tray(ICONS.gray);
277
+ buildMenu(tray);
278
+ setInterval(() => buildMenu(tray), REFRESH_MS);
279
+ });
280
+
281
+ app.on('window-all-closed', (e) => e.preventDefault());
package/tray/prompt.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const cp = require('child_process');
4
+
5
+ function promptText(message, defaultAnswer = '') {
6
+ const escape = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
7
+
8
+ if (process.platform === 'darwin') {
9
+ try {
10
+ const out = cp.execFileSync('osascript', ['-e',
11
+ `display dialog "${escape(message)}" default answer "${escape(defaultAnswer)}" with title "EnvSync"`,
12
+ ]).toString();
13
+ const match = out.match(/text returned:(.*)$/s);
14
+ return match ? match[1].trim() : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ if (process.platform === 'win32') {
21
+ const escapePowerShell = (s) => s.replace(/'/g, "''");
22
+ const script = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.Interaction]::InputBox('${escapePowerShell(message)}', 'EnvSync', '${escapePowerShell(defaultAnswer)}')`;
23
+ try {
24
+ const out = cp.execFileSync('powershell', ['-NoProfile', '-Command', script]).toString().trim();
25
+ return out || null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ if (process.platform === 'linux') {
32
+ try {
33
+ return cp.execFileSync('zenity', ['--entry', '--title=EnvSync', `--text=${message}`, `--entry-text=${defaultAnswer}`]).toString().trim();
34
+ } catch (err) {
35
+ if (err.code === 'ENOENT') {
36
+ try {
37
+ return cp.execFileSync('kdialog', ['--inputbox', message, defaultAnswer]).toString().trim();
38
+ } catch (fallbackErr) {
39
+ if (fallbackErr.code === 'ENOENT') {
40
+ console.error('Install zenity or kdialog to use this feature on Linux, or use the CLI instead.');
41
+ return null;
42
+ }
43
+ return null;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ module.exports = { promptText };