brew-tui 0.4.0 → 0.5.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/README.md +50 -16
- package/build/{brewbar-installer-SFVZEIA6.js → brewbar-installer-ZEMXNDHP.js} +13 -3
- package/build/{brewbar-installer-SFVZEIA6.js.map → brewbar-installer-ZEMXNDHP.js.map} +1 -1
- package/build/brewfile-manager-3SERRYNC.js +20 -0
- package/build/chunk-42URLVAJ.js +299 -0
- package/build/chunk-42URLVAJ.js.map +1 -0
- package/build/chunk-4I344KQX.js +221 -0
- package/build/chunk-4I344KQX.js.map +1 -0
- package/build/chunk-KDHEUNRI.js +62 -0
- package/build/chunk-KDHEUNRI.js.map +1 -0
- package/build/{chunk-PPSKR6PA.js → chunk-KDJNCZD7.js} +239 -17
- package/build/chunk-KDJNCZD7.js.map +1 -0
- package/build/chunk-KN4GCMIE.js +48 -0
- package/build/chunk-KN4GCMIE.js.map +1 -0
- package/build/{chunk-65YZJX2E.js → chunk-KVCVIRWI.js} +6 -20
- package/build/chunk-KVCVIRWI.js.map +1 -0
- package/build/chunk-LXF72RCD.js +467 -0
- package/build/chunk-LXF72RCD.js.map +1 -0
- package/build/chunk-U2DRWB7A.js +123 -0
- package/build/chunk-U2DRWB7A.js.map +1 -0
- package/build/chunk-UWS4A4F5.js +25 -0
- package/build/chunk-UWS4A4F5.js.map +1 -0
- package/build/compliance-checker-X7P623UF.js +12 -0
- package/build/compliance-checker-X7P623UF.js.map +1 -0
- package/build/{history-logger-2PGYSPFL.js → history-logger-PBDOLKNJ.js} +3 -2
- package/build/history-logger-PBDOLKNJ.js.map +1 -0
- package/build/index.js +2019 -452
- package/build/index.js.map +1 -1
- package/build/policy-io-EECGRKNA.js +11 -0
- package/build/policy-io-EECGRKNA.js.map +1 -0
- package/build/snapshot-RAPGMAJF.js +17 -0
- package/build/snapshot-RAPGMAJF.js.map +1 -0
- package/build/sync-engine-4ERSW4EQ.js +18 -0
- package/build/sync-engine-4ERSW4EQ.js.map +1 -0
- package/package.json +11 -9
- package/build/chunk-65YZJX2E.js.map +0 -1
- package/build/chunk-PPSKR6PA.js.map +0 -1
- /package/build/{history-logger-2PGYSPFL.js.map → brewfile-manager-3SERRYNC.js.map} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Brew-TUI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
### Your Homebrew, finally visible.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/brew-tui)
|
|
6
6
|
[](https://nodejs.org/)
|
|
@@ -8,27 +8,45 @@
|
|
|
8
8
|
[](https://github.com/MoLinesGitHub/homebrew-tap)
|
|
9
9
|
[]()
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
A keyboard-driven terminal UI for Homebrew, with a native macOS menu bar companion that watches updates in the background. No daemons, no middleware — both tools call `brew` directly.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
brew tap MoLinesGitHub/tap
|
|
17
|
+
brew install brew-tui # then just type: brew-tui
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why Brew-TUI?
|
|
23
|
+
|
|
24
|
+
You don't memorize `brew outdated && brew upgrade && brew services list && brew leaves`. You forget half of them. Brew-TUI puts every command behind one keystroke and shows you what `brew` never tells you until something breaks: orphans, vulnerabilities, services that died last Tuesday.
|
|
25
|
+
|
|
26
|
+
| Without Brew-TUI | With Brew-TUI |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `brew outdated` → wall of text → grep | Press **3** → list with version arrows → `Enter` to upgrade |
|
|
29
|
+
| `brew services list` → restart by hand | Press **4** → toggle services with one key |
|
|
30
|
+
| Vulnerable packages? | Press **9** → cross-checked against [OSV.dev](https://osv.dev) (Pro) |
|
|
31
|
+
| Forgot to update? | **BrewBar** lives in your menu bar and tells you (Pro) |
|
|
14
32
|
|
|
15
33
|
---
|
|
16
34
|
|
|
17
35
|
## Install
|
|
18
36
|
|
|
19
37
|
```bash
|
|
20
|
-
#
|
|
21
|
-
npm install -g brew-tui
|
|
22
|
-
|
|
23
|
-
# Homebrew
|
|
38
|
+
# Homebrew (recommended)
|
|
24
39
|
brew tap MoLinesGitHub/tap
|
|
25
40
|
brew install brew-tui
|
|
26
41
|
|
|
42
|
+
# npm
|
|
43
|
+
npm install -g brew-tui
|
|
44
|
+
|
|
27
45
|
# Run without installing
|
|
28
46
|
npx brew-tui
|
|
29
47
|
```
|
|
30
48
|
|
|
31
|
-
**Requirements:** Node.js
|
|
49
|
+
**Requirements:** Homebrew, macOS. The Homebrew formula installs the required Node.js runtime dependency.
|
|
32
50
|
|
|
33
51
|
---
|
|
34
52
|
|
|
@@ -44,15 +62,31 @@ npx brew-tui
|
|
|
44
62
|
| **Doctor** | Run `brew doctor` and see warnings at a glance |
|
|
45
63
|
| **Package Info** | Detailed view with dependencies, caveats, and quick actions |
|
|
46
64
|
|
|
47
|
-
### Pro Features
|
|
65
|
+
### Pro Features — $19 once, lifetime
|
|
48
66
|
|
|
49
|
-
| Feature |
|
|
50
|
-
|
|
51
|
-
| **Profiles** |
|
|
52
|
-
| **Smart Cleanup** |
|
|
53
|
-
| **Action History** |
|
|
54
|
-
| **Security Audit** |
|
|
55
|
-
| **BrewBar** |
|
|
67
|
+
| Feature | What it solves |
|
|
68
|
+
|---------|----------------|
|
|
69
|
+
| **Profiles** | Replicate your exact setup on a new Mac in one command |
|
|
70
|
+
| **Smart Cleanup** | Reclaim gigabytes by listing orphans ranked by size |
|
|
71
|
+
| **Action History** | "What did I install last week?" — answered |
|
|
72
|
+
| **Security Audit** | Get notified when [OSV.dev](https://osv.dev) flags something you have installed |
|
|
73
|
+
| **BrewBar** | A menu bar app that watches your packages while you sleep — auto-installs and auto-launches the moment you go Pro |
|
|
74
|
+
|
|
75
|
+
> Pro is one-time payment, lifetime updates, machine-bound. No subscription. [Activate →](https://molinesdesigns.com/brewtui/pro)
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Screenshots
|
|
80
|
+
|
|
81
|
+
| Dashboard | Outdated |
|
|
82
|
+
|---|---|
|
|
83
|
+
|  |  |
|
|
84
|
+
| **Services** | **Doctor** |
|
|
85
|
+
|  |  |
|
|
86
|
+
| **Smart Cleanup (Pro)** | **Security Audit (Pro)** |
|
|
87
|
+
|  |  |
|
|
88
|
+
|
|
89
|
+
> Smart Cleanup ranks orphans by size — find your 34 MB unused `jpeg-xl` in 2 seconds. Security Audit cross-checks every installed package against [OSV.dev](https://osv.dev) live.
|
|
56
90
|
|
|
57
91
|
---
|
|
58
92
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
fetchWithTimeout,
|
|
3
3
|
t
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-KDJNCZD7.js";
|
|
5
|
+
import "./chunk-KDHEUNRI.js";
|
|
5
6
|
|
|
6
7
|
// src/lib/brewbar-installer.ts
|
|
7
8
|
import { rm, access, readFile } from "fs/promises";
|
|
@@ -98,12 +99,20 @@ async function installBrewBar(isPro, force = false) {
|
|
|
98
99
|
try {
|
|
99
100
|
await execFileAsync("ditto", ["-xk", TMP_ZIP, "/Applications/"]);
|
|
100
101
|
} catch (err) {
|
|
101
|
-
throw new Error(t("cli_brewbarDownloadFailed", { error: err instanceof Error ? err.message : String(err) }));
|
|
102
|
+
throw new Error(t("cli_brewbarDownloadFailed", { error: err instanceof Error ? err.message : String(err) }), { cause: err });
|
|
102
103
|
} finally {
|
|
103
104
|
await rm(TMP_ZIP, { force: true }).catch(() => {
|
|
104
105
|
});
|
|
105
106
|
}
|
|
106
107
|
}
|
|
108
|
+
async function launchBrewBar() {
|
|
109
|
+
if (process.platform !== "darwin") return;
|
|
110
|
+
if (!await isBrewBarInstalled()) return;
|
|
111
|
+
try {
|
|
112
|
+
await execFileAsync("open", ["-g", "-a", BREWBAR_APP_PATH]);
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
}
|
|
107
116
|
async function uninstallBrewBar() {
|
|
108
117
|
if (!await isBrewBarInstalled()) {
|
|
109
118
|
throw new Error(t("cli_brewbarNotInstalled"));
|
|
@@ -113,6 +122,7 @@ async function uninstallBrewBar() {
|
|
|
113
122
|
export {
|
|
114
123
|
installBrewBar,
|
|
115
124
|
isBrewBarInstalled,
|
|
125
|
+
launchBrewBar,
|
|
116
126
|
uninstallBrewBar
|
|
117
127
|
};
|
|
118
|
-
//# sourceMappingURL=brewbar-installer-
|
|
128
|
+
//# sourceMappingURL=brewbar-installer-ZEMXNDHP.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/lib/brewbar-installer.ts"],"sourcesContent":["import { rm, access, readFile } from 'node:fs/promises';\nimport { createWriteStream } from 'node:fs';\nimport { createHash, randomUUID } from 'node:crypto';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { pipeline } from 'node:stream/promises';\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport { t } from '../i18n/index.js';\nimport { fetchWithTimeout } from './fetch-timeout.js';\n\nconst execFileAsync = promisify(execFile);\nconst BREWBAR_APP_PATH = '/Applications/BrewBar.app';\nconst DOWNLOAD_URL = 'https://github.com/MoLinesGitHub/Brew-TUI/releases/latest/download/BrewBar.app.zip';\nconst MAX_SIZE = 200 * 1024 * 1024; // 200 MB\n\nexport async function isBrewBarInstalled(): Promise<boolean> {\n try {\n await access(BREWBAR_APP_PATH);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function installBrewBar(isPro: boolean, force = false): Promise<void> {\n // macOS only\n if (process.platform !== 'darwin') {\n throw new Error(t('cli_brewbarMacOnly'));\n }\n\n // Pro check\n if (!isPro) {\n throw new Error(t('cli_brewbarProRequired'));\n }\n\n // Already installed check\n if (!force && await isBrewBarInstalled()) {\n throw new Error(t('cli_brewbarAlreadyInstalled'));\n }\n\n console.log(t('cli_brewbarInstalling'));\n\n // EP-013: Use unique temp path\n const TMP_ZIP = join(tmpdir(), 'BrewBar-' + randomUUID() + '.zip');\n\n // Download zip (120s timeout for large binary)\n const res = await fetchWithTimeout(DOWNLOAD_URL, {}, 120_000);\n if (!res.ok || !res.body) {\n throw new Error(t('cli_brewbarDownloadFailed', { error: `HTTP ${res.status}` }));\n }\n\n // Reject downloads larger than 200 MB (from Content-Length header)\n const contentLength = Number(res.headers.get('content-length') ?? '0');\n if (contentLength > MAX_SIZE) {\n throw new Error(t('cli_brewbarDownloadFailed', { error: 'Download exceeds 200 MB size limit' }));\n }\n\n // EP-005: Track downloaded bytes during the stream\n let downloadedBytes = 0;\n\n // Write to tmp file with byte counting\n const fileStream = createWriteStream(TMP_ZIP);\n const transformedBody = new ReadableStream({\n async start(controller) {\n const bodyReader = (res.body as ReadableStream<Uint8Array>).getReader();\n try {\n while (true) {\n const { done, value } = await bodyReader.read();\n if (done) break;\n downloadedBytes += value.length;\n if (downloadedBytes > MAX_SIZE) {\n controller.error(new Error('Download exceeds 200 MB limit'));\n return;\n }\n controller.enqueue(value);\n }\n controller.close();\n } catch (err) {\n controller.error(err);\n }\n },\n });\n await pipeline(transformedBody as unknown as NodeJS.ReadableStream, fileStream);\n\n // SEG-001: SHA-256 integrity check with proper error handling\n let expectedHash: string | null = null;\n try {\n const checksumRes = await fetchWithTimeout(`${DOWNLOAD_URL}.sha256`, {}, 15_000);\n if (checksumRes.ok) {\n const text = await checksumRes.text();\n // EP-009: Validate split result is defined\n const hash = text.trim().split(/\\s+/)[0];\n // EP-010: Validate hash format\n if (hash && /^[0-9a-f]{64}$/i.test(hash)) {\n expectedHash = hash.toLowerCase();\n }\n }\n } catch {\n /* checksum file not available */\n }\n\n if (expectedHash) {\n const fileBuffer = await readFile(TMP_ZIP);\n const actual = createHash('sha256').update(fileBuffer).digest('hex');\n if (actual !== expectedHash) {\n await rm(TMP_ZIP, { force: true }).catch(() => {});\n throw new Error(t('cli_brewbarDownloadFailed', { error: 'SHA-256 mismatch: binary may have been tampered with' }));\n }\n } else {\n // NUEVO-003: Treat missing checksum as fatal — don't install unverified binaries\n await rm(TMP_ZIP, { force: true }).catch(() => {});\n throw new Error(t('cli_brewbarDownloadFailed', { error: 'SHA-256 checksum unavailable — cannot verify download integrity' }));\n }\n\n // Remove old app if force reinstall\n if (force && await isBrewBarInstalled()) {\n await rm(BREWBAR_APP_PATH, { recursive: true, force: true });\n }\n\n // Unzip to /Applications\n try {\n await execFileAsync('ditto', ['-xk', TMP_ZIP, '/Applications/']);\n } catch (err) {\n throw new Error(t('cli_brewbarDownloadFailed', { error: err instanceof Error ? err.message : String(err) }));\n } finally {\n // Clean up tmp zip\n await rm(TMP_ZIP, { force: true }).catch(() => {});\n }\n}\n\nexport async function uninstallBrewBar(): Promise<void> {\n if (!await isBrewBarInstalled()) {\n throw new Error(t('cli_brewbarNotInstalled'));\n }\n\n await rm(BREWBAR_APP_PATH, { recursive: true, force: true });\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/lib/brewbar-installer.ts"],"sourcesContent":["import { rm, access, readFile } from 'node:fs/promises';\nimport { createWriteStream } from 'node:fs';\nimport { createHash, randomUUID } from 'node:crypto';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { pipeline } from 'node:stream/promises';\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport { t } from '../i18n/index.js';\nimport { fetchWithTimeout } from './fetch-timeout.js';\n\nconst execFileAsync = promisify(execFile);\nconst BREWBAR_APP_PATH = '/Applications/BrewBar.app';\nconst DOWNLOAD_URL = 'https://github.com/MoLinesGitHub/Brew-TUI/releases/latest/download/BrewBar.app.zip';\nconst MAX_SIZE = 200 * 1024 * 1024; // 200 MB\n\nexport async function isBrewBarInstalled(): Promise<boolean> {\n try {\n await access(BREWBAR_APP_PATH);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function installBrewBar(isPro: boolean, force = false): Promise<void> {\n // macOS only\n if (process.platform !== 'darwin') {\n throw new Error(t('cli_brewbarMacOnly'));\n }\n\n // Pro check\n if (!isPro) {\n throw new Error(t('cli_brewbarProRequired'));\n }\n\n // Already installed check\n if (!force && await isBrewBarInstalled()) {\n throw new Error(t('cli_brewbarAlreadyInstalled'));\n }\n\n console.log(t('cli_brewbarInstalling'));\n\n // EP-013: Use unique temp path\n const TMP_ZIP = join(tmpdir(), 'BrewBar-' + randomUUID() + '.zip');\n\n // Download zip (120s timeout for large binary)\n const res = await fetchWithTimeout(DOWNLOAD_URL, {}, 120_000);\n if (!res.ok || !res.body) {\n throw new Error(t('cli_brewbarDownloadFailed', { error: `HTTP ${res.status}` }));\n }\n\n // Reject downloads larger than 200 MB (from Content-Length header)\n const contentLength = Number(res.headers.get('content-length') ?? '0');\n if (contentLength > MAX_SIZE) {\n throw new Error(t('cli_brewbarDownloadFailed', { error: 'Download exceeds 200 MB size limit' }));\n }\n\n // EP-005: Track downloaded bytes during the stream\n let downloadedBytes = 0;\n\n // Write to tmp file with byte counting\n const fileStream = createWriteStream(TMP_ZIP);\n const transformedBody = new ReadableStream({\n async start(controller) {\n const bodyReader = (res.body as ReadableStream<Uint8Array>).getReader();\n try {\n while (true) {\n const { done, value } = await bodyReader.read();\n if (done) break;\n downloadedBytes += value.length;\n if (downloadedBytes > MAX_SIZE) {\n controller.error(new Error('Download exceeds 200 MB limit'));\n return;\n }\n controller.enqueue(value);\n }\n controller.close();\n } catch (err) {\n controller.error(err);\n }\n },\n });\n await pipeline(transformedBody as unknown as NodeJS.ReadableStream, fileStream);\n\n // SEG-001: SHA-256 integrity check with proper error handling\n let expectedHash: string | null = null;\n try {\n const checksumRes = await fetchWithTimeout(`${DOWNLOAD_URL}.sha256`, {}, 15_000);\n if (checksumRes.ok) {\n const text = await checksumRes.text();\n // EP-009: Validate split result is defined\n const hash = text.trim().split(/\\s+/)[0];\n // EP-010: Validate hash format\n if (hash && /^[0-9a-f]{64}$/i.test(hash)) {\n expectedHash = hash.toLowerCase();\n }\n }\n } catch {\n /* checksum file not available */\n }\n\n if (expectedHash) {\n const fileBuffer = await readFile(TMP_ZIP);\n const actual = createHash('sha256').update(fileBuffer).digest('hex');\n if (actual !== expectedHash) {\n await rm(TMP_ZIP, { force: true }).catch(() => {});\n throw new Error(t('cli_brewbarDownloadFailed', { error: 'SHA-256 mismatch: binary may have been tampered with' }));\n }\n } else {\n // NUEVO-003: Treat missing checksum as fatal — don't install unverified binaries\n await rm(TMP_ZIP, { force: true }).catch(() => {});\n throw new Error(t('cli_brewbarDownloadFailed', { error: 'SHA-256 checksum unavailable — cannot verify download integrity' }));\n }\n\n // Remove old app if force reinstall\n if (force && await isBrewBarInstalled()) {\n await rm(BREWBAR_APP_PATH, { recursive: true, force: true });\n }\n\n // Unzip to /Applications\n try {\n await execFileAsync('ditto', ['-xk', TMP_ZIP, '/Applications/']);\n } catch (err) {\n throw new Error(t('cli_brewbarDownloadFailed', { error: err instanceof Error ? err.message : String(err) }), { cause: err });\n } finally {\n // Clean up tmp zip\n await rm(TMP_ZIP, { force: true }).catch(() => {});\n }\n}\n\n/// Launches BrewBar detached from the parent process so it survives terminal close.\n/// `open -g -a` runs the app in the background without bringing it to foreground.\nexport async function launchBrewBar(): Promise<void> {\n if (process.platform !== 'darwin') return;\n if (!await isBrewBarInstalled()) return;\n try {\n await execFileAsync('open', ['-g', '-a', BREWBAR_APP_PATH]);\n } catch {\n // Non-fatal: BrewBar may already be running, or LaunchServices may need a moment.\n }\n}\n\nexport async function uninstallBrewBar(): Promise<void> {\n if (!await isBrewBarInstalled()) {\n throw new Error(t('cli_brewbarNotInstalled'));\n }\n\n await rm(BREWBAR_APP_PATH, { recursive: true, force: true });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BREWFILE_PATH,
|
|
3
|
+
computeDrift,
|
|
4
|
+
createDefaultBrewfile,
|
|
5
|
+
loadBrewfile,
|
|
6
|
+
reconcile,
|
|
7
|
+
saveBrewfile
|
|
8
|
+
} from "./chunk-LXF72RCD.js";
|
|
9
|
+
import "./chunk-4I344KQX.js";
|
|
10
|
+
import "./chunk-UWS4A4F5.js";
|
|
11
|
+
import "./chunk-KDHEUNRI.js";
|
|
12
|
+
export {
|
|
13
|
+
BREWFILE_PATH,
|
|
14
|
+
computeDrift,
|
|
15
|
+
createDefaultBrewfile,
|
|
16
|
+
loadBrewfile,
|
|
17
|
+
reconcile,
|
|
18
|
+
saveBrewfile
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=brewfile-manager-3SERRYNC.js.map
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
captureSnapshot
|
|
3
|
+
} from "./chunk-4I344KQX.js";
|
|
4
|
+
import {
|
|
5
|
+
DATA_DIR
|
|
6
|
+
} from "./chunk-UWS4A4F5.js";
|
|
7
|
+
import {
|
|
8
|
+
logger
|
|
9
|
+
} from "./chunk-KDHEUNRI.js";
|
|
10
|
+
|
|
11
|
+
// src/lib/sync/sync-engine.ts
|
|
12
|
+
import { readFile as readFile2, writeFile as writeFile2, rename as rename2 } from "fs/promises";
|
|
13
|
+
import { join as join2 } from "path";
|
|
14
|
+
import { hostname } from "os";
|
|
15
|
+
|
|
16
|
+
// src/lib/sync/crypto.ts
|
|
17
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
18
|
+
var ENCRYPTION_SECRET = "brew-tui-sync-aes256gcm-v1";
|
|
19
|
+
var SCRYPT_SALT = "brew-tui-sync-salt-v1";
|
|
20
|
+
var _derivedKey = null;
|
|
21
|
+
function deriveEncryptionKey() {
|
|
22
|
+
if (!_derivedKey) {
|
|
23
|
+
_derivedKey = scryptSync(ENCRYPTION_SECRET, SCRYPT_SALT, 32, {
|
|
24
|
+
N: 16384,
|
|
25
|
+
r: 8,
|
|
26
|
+
p: 1
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return _derivedKey;
|
|
30
|
+
}
|
|
31
|
+
function encryptPayload(data) {
|
|
32
|
+
const key = deriveEncryptionKey();
|
|
33
|
+
const iv = randomBytes(12);
|
|
34
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
35
|
+
const plaintext = JSON.stringify(data);
|
|
36
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
37
|
+
const tag = cipher.getAuthTag();
|
|
38
|
+
return {
|
|
39
|
+
encrypted: ciphertext.toString("base64"),
|
|
40
|
+
iv: iv.toString("base64"),
|
|
41
|
+
tag: tag.toString("base64")
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function decryptPayload(encrypted, iv, tag) {
|
|
45
|
+
const key = deriveEncryptionKey();
|
|
46
|
+
const decipher = createDecipheriv(
|
|
47
|
+
"aes-256-gcm",
|
|
48
|
+
key,
|
|
49
|
+
Buffer.from(iv, "base64")
|
|
50
|
+
);
|
|
51
|
+
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
|
52
|
+
const plaintext = Buffer.concat([
|
|
53
|
+
decipher.update(Buffer.from(encrypted, "base64")),
|
|
54
|
+
decipher.final()
|
|
55
|
+
]);
|
|
56
|
+
return JSON.parse(plaintext.toString("utf-8"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/lib/sync/backends/icloud-backend.ts
|
|
60
|
+
import { readFile, writeFile, rename, mkdir, stat } from "fs/promises";
|
|
61
|
+
import { homedir } from "os";
|
|
62
|
+
import { join } from "path";
|
|
63
|
+
var ICLOUD_BASE = join(
|
|
64
|
+
homedir(),
|
|
65
|
+
"Library",
|
|
66
|
+
"Mobile Documents",
|
|
67
|
+
"com~apple~CloudDocs"
|
|
68
|
+
);
|
|
69
|
+
var ICLOUD_SYNC_DIR = join(ICLOUD_BASE, "BrewTUI");
|
|
70
|
+
var ICLOUD_SYNC_PATH = join(ICLOUD_SYNC_DIR, "sync.json");
|
|
71
|
+
async function isICloudAvailable() {
|
|
72
|
+
try {
|
|
73
|
+
await stat(ICLOUD_BASE);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isValidEnvelope(v) {
|
|
80
|
+
if (!v || typeof v !== "object") return false;
|
|
81
|
+
const obj = v;
|
|
82
|
+
return obj["schemaVersion"] === 1 && typeof obj["encrypted"] === "string" && typeof obj["iv"] === "string" && typeof obj["tag"] === "string" && typeof obj["updatedAt"] === "string";
|
|
83
|
+
}
|
|
84
|
+
async function readSyncEnvelope() {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await readFile(ICLOUD_SYNC_PATH, "utf-8");
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
if (!isValidEnvelope(parsed)) {
|
|
89
|
+
logger.warn("sync: invalid envelope structure in iCloud file");
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
logger.warn("sync: could not read iCloud envelope", { error: String(err) });
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function writeSyncEnvelope(envelope) {
|
|
102
|
+
await mkdir(ICLOUD_SYNC_DIR, { recursive: true });
|
|
103
|
+
const tmpPath = ICLOUD_SYNC_PATH + ".tmp";
|
|
104
|
+
await writeFile(tmpPath, JSON.stringify(envelope, null, 2), {
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
mode: 420
|
|
107
|
+
});
|
|
108
|
+
await rename(tmpPath, ICLOUD_SYNC_PATH);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/lib/sync/sync-engine.ts
|
|
112
|
+
var SYNC_CONFIG_PATH = join2(DATA_DIR, "sync-config.json");
|
|
113
|
+
var MACHINE_ID_PATH = join2(DATA_DIR, "machine-id");
|
|
114
|
+
async function loadSyncConfig() {
|
|
115
|
+
try {
|
|
116
|
+
const raw = await readFile2(SYNC_CONFIG_PATH, "utf-8");
|
|
117
|
+
return JSON.parse(raw);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function saveSyncConfig(config) {
|
|
123
|
+
const tmpPath = SYNC_CONFIG_PATH + ".tmp";
|
|
124
|
+
await writeFile2(tmpPath, JSON.stringify(config, null, 2), {
|
|
125
|
+
encoding: "utf-8",
|
|
126
|
+
mode: 384
|
|
127
|
+
});
|
|
128
|
+
await rename2(tmpPath, SYNC_CONFIG_PATH);
|
|
129
|
+
}
|
|
130
|
+
async function getMachineId() {
|
|
131
|
+
try {
|
|
132
|
+
const id = (await readFile2(MACHINE_ID_PATH, "utf-8")).trim();
|
|
133
|
+
if (id) return id;
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
return hostname();
|
|
137
|
+
}
|
|
138
|
+
function detectConflicts(localSnapshot, otherMachines, localMachineId) {
|
|
139
|
+
const conflicts = [];
|
|
140
|
+
const localFormulaMap = new Map(localSnapshot.formulae.map((f) => [f.name, f.version]));
|
|
141
|
+
const localCaskMap = new Map(localSnapshot.casks.map((c) => [c.name, c.version]));
|
|
142
|
+
for (const machine of otherMachines) {
|
|
143
|
+
if (machine.machineId === localMachineId) continue;
|
|
144
|
+
for (const remoteFormula of machine.snapshot.formulae) {
|
|
145
|
+
const localVersion = localFormulaMap.get(remoteFormula.name);
|
|
146
|
+
if (localVersion !== void 0 && localVersion !== remoteFormula.version) {
|
|
147
|
+
conflicts.push({
|
|
148
|
+
packageName: remoteFormula.name,
|
|
149
|
+
packageType: "formula",
|
|
150
|
+
localVersion,
|
|
151
|
+
remoteMachine: machine.machineName,
|
|
152
|
+
remoteVersion: remoteFormula.version
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const remoteCask of machine.snapshot.casks) {
|
|
157
|
+
const localVersion = localCaskMap.get(remoteCask.name);
|
|
158
|
+
if (localVersion !== void 0 && localVersion !== remoteCask.version) {
|
|
159
|
+
conflicts.push({
|
|
160
|
+
packageName: remoteCask.name,
|
|
161
|
+
packageType: "cask",
|
|
162
|
+
localVersion,
|
|
163
|
+
remoteMachine: machine.machineName,
|
|
164
|
+
remoteVersion: remoteCask.version
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return conflicts;
|
|
170
|
+
}
|
|
171
|
+
async function writeEnvelope(payload) {
|
|
172
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
173
|
+
const { encrypted, iv, tag } = encryptPayload(payload);
|
|
174
|
+
const envelope = {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
encrypted,
|
|
177
|
+
iv,
|
|
178
|
+
tag,
|
|
179
|
+
updatedAt: now
|
|
180
|
+
};
|
|
181
|
+
await writeSyncEnvelope(envelope);
|
|
182
|
+
return now;
|
|
183
|
+
}
|
|
184
|
+
function mergePayload(existing, localState) {
|
|
185
|
+
return {
|
|
186
|
+
machines: {
|
|
187
|
+
...existing.machines,
|
|
188
|
+
[localState.machineId]: localState
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function sync(isPro, currentBrewfile) {
|
|
193
|
+
if (!isPro) {
|
|
194
|
+
throw new Error("Pro license required");
|
|
195
|
+
}
|
|
196
|
+
const available = await isICloudAvailable();
|
|
197
|
+
if (!available) {
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
conflicts: [],
|
|
201
|
+
resolvedCount: 0,
|
|
202
|
+
error: "iCloud Drive not available"
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
let existingPayload = null;
|
|
206
|
+
try {
|
|
207
|
+
const envelope = await readSyncEnvelope();
|
|
208
|
+
if (envelope) {
|
|
209
|
+
existingPayload = decryptPayload(envelope.encrypted, envelope.iv, envelope.tag);
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
logger.warn("sync: could not decrypt existing payload, starting fresh", { error: String(err) });
|
|
213
|
+
existingPayload = null;
|
|
214
|
+
}
|
|
215
|
+
const snapshot = await captureSnapshot();
|
|
216
|
+
const machineId = await getMachineId();
|
|
217
|
+
const machineName = hostname();
|
|
218
|
+
const localState = {
|
|
219
|
+
machineId,
|
|
220
|
+
machineName,
|
|
221
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
222
|
+
snapshot,
|
|
223
|
+
...currentBrewfile ? { brewfile: currentBrewfile } : {}
|
|
224
|
+
};
|
|
225
|
+
const otherMachines = existingPayload ? Object.values(existingPayload.machines).filter((m) => m.machineId !== machineId) : [];
|
|
226
|
+
const conflicts = detectConflicts(snapshot, otherMachines, machineId);
|
|
227
|
+
const basePayload = existingPayload ?? { machines: {} };
|
|
228
|
+
const mergedPayload = mergePayload(basePayload, localState);
|
|
229
|
+
if (conflicts.length > 0) {
|
|
230
|
+
await writeEnvelope(mergedPayload);
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
conflicts,
|
|
234
|
+
resolvedCount: 0
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const now = await writeEnvelope(mergedPayload);
|
|
238
|
+
const existingConfig = await loadSyncConfig();
|
|
239
|
+
await saveSyncConfig({
|
|
240
|
+
enabled: true,
|
|
241
|
+
machineId,
|
|
242
|
+
machineName,
|
|
243
|
+
...existingConfig ?? {},
|
|
244
|
+
lastSync: now
|
|
245
|
+
});
|
|
246
|
+
logger.info("sync: completed successfully", { machineId, machines: Object.keys(mergedPayload.machines).length });
|
|
247
|
+
return {
|
|
248
|
+
success: true,
|
|
249
|
+
conflicts: [],
|
|
250
|
+
resolvedCount: 0
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function applyConflictResolutions(payload, resolutions, localMachineId) {
|
|
254
|
+
const updatedPayload = {
|
|
255
|
+
machines: { ...payload.machines }
|
|
256
|
+
};
|
|
257
|
+
for (const { conflict, resolution } of resolutions) {
|
|
258
|
+
if (resolution !== "use-remote") continue;
|
|
259
|
+
const localMachine = updatedPayload.machines[localMachineId];
|
|
260
|
+
if (!localMachine) {
|
|
261
|
+
logger.warn("sync: cannot apply resolution, local machine missing in payload", { localMachineId });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (conflict.packageType === "formula") {
|
|
265
|
+
updatedPayload.machines[localMachineId] = {
|
|
266
|
+
...localMachine,
|
|
267
|
+
snapshot: {
|
|
268
|
+
...localMachine.snapshot,
|
|
269
|
+
formulae: localMachine.snapshot.formulae.map(
|
|
270
|
+
(f) => f.name === conflict.packageName ? { ...f, version: conflict.remoteVersion } : f
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
} else {
|
|
275
|
+
updatedPayload.machines[localMachineId] = {
|
|
276
|
+
...localMachine,
|
|
277
|
+
snapshot: {
|
|
278
|
+
...localMachine.snapshot,
|
|
279
|
+
casks: localMachine.snapshot.casks.map(
|
|
280
|
+
(c) => c.name === conflict.packageName ? { ...c, version: conflict.remoteVersion } : c
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
await writeEnvelope(updatedPayload);
|
|
287
|
+
logger.info("sync: conflict resolutions applied", { count: resolutions.length });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export {
|
|
291
|
+
decryptPayload,
|
|
292
|
+
readSyncEnvelope,
|
|
293
|
+
loadSyncConfig,
|
|
294
|
+
saveSyncConfig,
|
|
295
|
+
getMachineId,
|
|
296
|
+
sync,
|
|
297
|
+
applyConflictResolutions
|
|
298
|
+
};
|
|
299
|
+
//# sourceMappingURL=chunk-42URLVAJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lib/sync/sync-engine.ts","../src/lib/sync/crypto.ts","../src/lib/sync/backends/icloud-backend.ts"],"sourcesContent":["import { readFile, writeFile, rename } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { hostname } from 'node:os';\nimport { encryptPayload, decryptPayload } from './crypto.js';\nimport {\n readSyncEnvelope,\n writeSyncEnvelope,\n isICloudAvailable,\n} from './backends/icloud-backend.js';\nimport { captureSnapshot } from '../state-snapshot/snapshot.js';\nimport { DATA_DIR } from '../data-dir.js';\nimport { logger } from '../../utils/logger.js';\nimport type {\n SyncConfig,\n SyncPayload,\n SyncConflict,\n SyncResult,\n MachineState,\n SyncEnvelope,\n} from './types.js';\nimport type { BrewfileSchema } from '../brewfile/types.js';\n\nconst SYNC_CONFIG_PATH = join(DATA_DIR, 'sync-config.json');\nconst MACHINE_ID_PATH = join(DATA_DIR, 'machine-id');\n\n// ── Config I/O ──────────────────────────────────────────────────────────────\n\nexport async function loadSyncConfig(): Promise<SyncConfig | null> {\n try {\n const raw = await readFile(SYNC_CONFIG_PATH, 'utf-8');\n return JSON.parse(raw) as SyncConfig;\n } catch {\n return null;\n }\n}\n\nexport async function saveSyncConfig(config: SyncConfig): Promise<void> {\n const tmpPath = SYNC_CONFIG_PATH + '.tmp';\n await writeFile(tmpPath, JSON.stringify(config, null, 2), {\n encoding: 'utf-8',\n mode: 0o600,\n });\n await rename(tmpPath, SYNC_CONFIG_PATH);\n}\n\n// ── Machine ID ───────────────────────────────────────────────────────────────\n\nexport async function getMachineId(): Promise<string> {\n try {\n const id = (await readFile(MACHINE_ID_PATH, 'utf-8')).trim();\n if (id) return id;\n } catch { /* machine-id created by polar-api on first activation */ }\n return hostname(); // Fallback: hostname if machine-id not yet created\n}\n\n// ── Conflict detection ───────────────────────────────────────────────────────\n\nfunction detectConflicts(\n localSnapshot: { formulae: Array<{ name: string; version: string }>; casks: Array<{ name: string; version: string }> },\n otherMachines: MachineState[],\n localMachineId: string,\n): SyncConflict[] {\n const conflicts: SyncConflict[] = [];\n\n const localFormulaMap = new Map(localSnapshot.formulae.map((f) => [f.name, f.version]));\n const localCaskMap = new Map(localSnapshot.casks.map((c) => [c.name, c.version]));\n\n for (const machine of otherMachines) {\n if (machine.machineId === localMachineId) continue;\n\n // Check formula conflicts: same package, different version on both machines\n for (const remoteFormula of machine.snapshot.formulae) {\n const localVersion = localFormulaMap.get(remoteFormula.name);\n if (localVersion !== undefined && localVersion !== remoteFormula.version) {\n conflicts.push({\n packageName: remoteFormula.name,\n packageType: 'formula',\n localVersion,\n remoteMachine: machine.machineName,\n remoteVersion: remoteFormula.version,\n });\n }\n }\n\n // Check cask conflicts\n for (const remoteCask of machine.snapshot.casks) {\n const localVersion = localCaskMap.get(remoteCask.name);\n if (localVersion !== undefined && localVersion !== remoteCask.version) {\n conflicts.push({\n packageName: remoteCask.name,\n packageType: 'cask',\n localVersion,\n remoteMachine: machine.machineName,\n remoteVersion: remoteCask.version,\n });\n }\n }\n }\n\n return conflicts;\n}\n\n// ── Merge ────────────────────────────────────────────────────────────────────\n\nasync function writeEnvelope(payload: SyncPayload): Promise<string> {\n const now = new Date().toISOString();\n const { encrypted, iv, tag } = encryptPayload(payload);\n const envelope: SyncEnvelope = {\n schemaVersion: 1,\n encrypted,\n iv,\n tag,\n updatedAt: now,\n };\n await writeSyncEnvelope(envelope);\n return now;\n}\n\nfunction mergePayload(existing: SyncPayload, localState: MachineState): SyncPayload {\n return {\n machines: {\n ...existing.machines,\n [localState.machineId]: localState,\n },\n };\n}\n\n// ── Main sync function ───────────────────────────────────────────────────────\n\nexport async function sync(\n isPro: boolean,\n currentBrewfile?: BrewfileSchema,\n): Promise<SyncResult> {\n if (!isPro) {\n throw new Error('Pro license required');\n }\n\n const available = await isICloudAvailable();\n if (!available) {\n return {\n success: false,\n conflicts: [],\n resolvedCount: 0,\n error: 'iCloud Drive not available',\n };\n }\n\n let existingPayload: SyncPayload | null = null;\n\n try {\n const envelope = await readSyncEnvelope();\n if (envelope) {\n existingPayload = decryptPayload(envelope.encrypted, envelope.iv, envelope.tag);\n }\n } catch (err) {\n logger.warn('sync: could not decrypt existing payload, starting fresh', { error: String(err) });\n existingPayload = null;\n }\n\n // Capture current local state\n const snapshot = await captureSnapshot();\n const machineId = await getMachineId();\n const machineName = hostname();\n\n const localState: MachineState = {\n machineId,\n machineName,\n updatedAt: new Date().toISOString(),\n snapshot,\n ...(currentBrewfile ? { brewfile: currentBrewfile } : {}),\n };\n\n // Detect conflicts against other machines in the payload\n const otherMachines = existingPayload\n ? Object.values(existingPayload.machines).filter((m) => m.machineId !== machineId)\n : [];\n\n const conflicts = detectConflicts(snapshot, otherMachines, machineId);\n\n // Always write the local machine state to the payload, even when conflicts\n // exist, so that applyConflictResolutions() has a local entry to update.\n // Without this, the iCloud envelope keeps only remote machines, and\n // resolution updates are silently dropped (they require localMachine to exist).\n const basePayload: SyncPayload = existingPayload ?? { machines: {} };\n const mergedPayload = mergePayload(basePayload, localState);\n\n if (conflicts.length > 0) {\n // Persist local state, then surface conflicts so the user can resolve them.\n await writeEnvelope(mergedPayload);\n return {\n success: false,\n conflicts,\n resolvedCount: 0,\n };\n }\n\n const now = await writeEnvelope(mergedPayload);\n\n // Update local sync config\n const existingConfig = await loadSyncConfig();\n await saveSyncConfig({\n enabled: true,\n machineId,\n machineName,\n ...(existingConfig ?? {}),\n lastSync: now,\n });\n\n logger.info('sync: completed successfully', { machineId, machines: Object.keys(mergedPayload.machines).length });\n\n return {\n success: true,\n conflicts: [],\n resolvedCount: 0,\n };\n}\n\n// ── Conflict resolution ──────────────────────────────────────────────────────\n\nexport async function applyConflictResolutions(\n payload: SyncPayload,\n resolutions: Array<{ conflict: SyncConflict; resolution: 'use-local' | 'use-remote' }>,\n localMachineId: string,\n): Promise<void> {\n // Work on a mutable copy\n const updatedPayload: SyncPayload = {\n machines: { ...payload.machines },\n };\n\n for (const { conflict, resolution } of resolutions) {\n if (resolution !== 'use-remote') continue;\n // Re-read latest local machine on every iteration so consecutive resolutions\n // build on top of each other instead of overwriting prior changes.\n const localMachine = updatedPayload.machines[localMachineId];\n if (!localMachine) {\n logger.warn('sync: cannot apply resolution, local machine missing in payload', { localMachineId });\n continue;\n }\n if (conflict.packageType === 'formula') {\n updatedPayload.machines[localMachineId] = {\n ...localMachine,\n snapshot: {\n ...localMachine.snapshot,\n formulae: localMachine.snapshot.formulae.map((f) =>\n f.name === conflict.packageName\n ? { ...f, version: conflict.remoteVersion }\n : f,\n ),\n },\n };\n } else {\n updatedPayload.machines[localMachineId] = {\n ...localMachine,\n snapshot: {\n ...localMachine.snapshot,\n casks: localMachine.snapshot.casks.map((c) =>\n c.name === conflict.packageName\n ? { ...c, version: conflict.remoteVersion }\n : c,\n ),\n },\n };\n }\n }\n\n await writeEnvelope(updatedPayload);\n logger.info('sync: conflict resolutions applied', { count: resolutions.length });\n}\n","import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';\nimport type { SyncPayload } from './types.js';\n\n// Cross-machine sync encryption — shared secret (no machine binding by design,\n// the same user's machines must decrypt each other's payloads).\nconst ENCRYPTION_SECRET = 'brew-tui-sync-aes256gcm-v1';\nconst SCRYPT_SALT = 'brew-tui-sync-salt-v1';\n\n// Lazy derivation — scryptSync is CPU-intensive and should not block on import.\nlet _derivedKey: Buffer | null = null;\n\nfunction deriveEncryptionKey(): Buffer {\n if (!_derivedKey) {\n _derivedKey = scryptSync(ENCRYPTION_SECRET, SCRYPT_SALT, 32, {\n N: 16384,\n r: 8,\n p: 1,\n });\n }\n return _derivedKey;\n}\n\nexport function encryptPayload(data: SyncPayload): { encrypted: string; iv: string; tag: string } {\n const key = deriveEncryptionKey();\n const iv = randomBytes(12); // 96-bit IV for GCM\n const cipher = createCipheriv('aes-256-gcm', key, iv);\n\n const plaintext = JSON.stringify(data);\n const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);\n const tag = cipher.getAuthTag();\n\n return {\n encrypted: ciphertext.toString('base64'),\n iv: iv.toString('base64'),\n tag: tag.toString('base64'),\n };\n}\n\nexport function decryptPayload(encrypted: string, iv: string, tag: string): SyncPayload {\n const key = deriveEncryptionKey();\n const decipher = createDecipheriv(\n 'aes-256-gcm',\n key,\n Buffer.from(iv, 'base64'),\n );\n decipher.setAuthTag(Buffer.from(tag, 'base64'));\n\n const plaintext = Buffer.concat([\n decipher.update(Buffer.from(encrypted, 'base64')),\n decipher.final(),\n ]);\n\n return JSON.parse(plaintext.toString('utf-8')) as SyncPayload;\n}\n","import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { logger } from '../../../utils/logger.js';\nimport type { SyncEnvelope } from '../types.js';\n\nconst ICLOUD_BASE = join(\n homedir(),\n 'Library', 'Mobile Documents', 'com~apple~CloudDocs',\n);\nexport const ICLOUD_SYNC_DIR = join(ICLOUD_BASE, 'BrewTUI');\nexport const ICLOUD_SYNC_PATH = join(ICLOUD_SYNC_DIR, 'sync.json');\n\nexport async function isICloudAvailable(): Promise<boolean> {\n try {\n await stat(ICLOUD_BASE);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction isValidEnvelope(v: unknown): v is SyncEnvelope {\n if (!v || typeof v !== 'object') return false;\n const obj = v as Record<string, unknown>;\n return (\n obj['schemaVersion'] === 1 &&\n typeof obj['encrypted'] === 'string' &&\n typeof obj['iv'] === 'string' &&\n typeof obj['tag'] === 'string' &&\n typeof obj['updatedAt'] === 'string'\n );\n}\n\nexport async function readSyncEnvelope(): Promise<SyncEnvelope | null> {\n try {\n const raw = await readFile(ICLOUD_SYNC_PATH, 'utf-8');\n const parsed: unknown = JSON.parse(raw);\n if (!isValidEnvelope(parsed)) {\n logger.warn('sync: invalid envelope structure in iCloud file');\n return null;\n }\n return parsed;\n } catch (err: unknown) {\n // File does not exist yet — expected on first sync\n if (\n err instanceof Error &&\n (err as NodeJS.ErrnoException).code === 'ENOENT'\n ) {\n return null;\n }\n logger.warn('sync: could not read iCloud envelope', { error: String(err) });\n return null;\n }\n}\n\nexport async function writeSyncEnvelope(envelope: SyncEnvelope): Promise<void> {\n await mkdir(ICLOUD_SYNC_DIR, { recursive: true });\n const tmpPath = ICLOUD_SYNC_PATH + '.tmp';\n await writeFile(tmpPath, JSON.stringify(envelope, null, 2), {\n encoding: 'utf-8',\n mode: 0o644,\n });\n await rename(tmpPath, ICLOUD_SYNC_PATH);\n}\n"],"mappings":"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","names":["readFile","writeFile","rename","join","join","readFile","writeFile","rename"]}
|