pyre-cli 9.5.1 → 10.0.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.
Files changed (94) hide show
  1. package/README.md +84 -14
  2. package/dist/alertChannels-TPSQIJ7S.js +8 -0
  3. package/dist/blender-FGH3ASEQ.js +7 -0
  4. package/dist/brewHealth-P2GW5Y5C.js +176 -0
  5. package/dist/buildCmd-CDPZ2ZFA.js +38 -0
  6. package/dist/{checkCmd-EWFNLBWE.js → checkCmd-RKW6QGDR.js} +10 -11
  7. package/dist/{chunk-RVGKEJXT.js → chunk-53O2RGSL.js} +47 -17
  8. package/dist/{chunk-MOAEGMFP.js → chunk-6UHZP4QP.js} +204 -46
  9. package/dist/chunk-GHRDW646.js +2506 -0
  10. package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
  11. package/dist/chunk-M73HCQD2.js +95 -0
  12. package/dist/chunk-MZO5QKUR.js +32 -0
  13. package/dist/chunk-VIACN2RU.js +133 -0
  14. package/dist/chunk-Z2YV5HKV.js +87 -0
  15. package/dist/chunk-ZLVIVFSO.js +110 -0
  16. package/dist/{collectors-WD6VKH6M.js → collectors-H43PN4PS.js} +8 -2
  17. package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
  18. package/dist/{config-4ZLF4HTS.js → config-GYGJXU2Q.js} +9 -1
  19. package/dist/doctor-GYHFTNZQ.js +387 -0
  20. package/dist/explainCmd-MF6PIMLW.js +222 -0
  21. package/dist/extensions-XJGHG7X3.js +134 -0
  22. package/dist/index.js +841 -206
  23. package/dist/ioreport.py +72 -0
  24. package/dist/netUsageCmd-WKBTW2YG.js +90 -0
  25. package/dist/{pipeCmd-7THXEGDH.js → pipeCmd-7REDBLBU.js} +5 -3
  26. package/dist/profileProcCmd-N4H6MCBT.js +107 -0
  27. package/dist/prometheus-5BL5PVLC.js +6 -0
  28. package/dist/prometheusCmd-PL64BLN5.js +93 -0
  29. package/dist/replayCmd-Z3PYXY7V.js +164 -0
  30. package/dist/run-HOGQDDTA.js +8 -0
  31. package/dist/serveCmd-2H5VLF3P.js +153 -0
  32. package/dist/service-Q74BBILO.js +181 -0
  33. package/dist/smartCmd-ODWKSFNG.js +166 -0
  34. package/dist/syncCmd-A2W3QB3S.js +126 -0
  35. package/dist/topoCmd-DU2HHZNH.js +148 -0
  36. package/dist/watchdogCmd-UF53JPZB.js +161 -0
  37. package/dist/{wizard-GVIAHIVV.js → wizard-TD7BGX6O.js} +1 -1
  38. package/install.ps1 +29 -0
  39. package/install.sh +10 -9
  40. package/package.json +7 -3
  41. package/src/ai/models.ts +138 -0
  42. package/src/alertChannels.ts +119 -0
  43. package/src/anomalies.ts +114 -60
  44. package/src/brewHealth.ts +157 -40
  45. package/src/buildCmd.ts +48 -0
  46. package/src/checkCmd.ts +5 -5
  47. package/src/completions.ts +35 -0
  48. package/src/doctor.ts +235 -7
  49. package/src/easy_features.test.ts +40 -0
  50. package/src/explainCmd.ts +261 -0
  51. package/src/extensions.ts +71 -7
  52. package/src/formatters/index.ts +1 -1
  53. package/src/formatters/output.ts +100 -13
  54. package/src/formatters/render.ts +274 -38
  55. package/src/formatters/themes.ts +8 -0
  56. package/src/formatters/types.ts +6 -1
  57. package/src/index.ts +298 -56
  58. package/src/live/input.ts +122 -4
  59. package/src/live/render.ts +98 -0
  60. package/src/live/state.ts +70 -52
  61. package/src/live/types.ts +2 -2
  62. package/src/monitors/batteryPredictor.ts +143 -0
  63. package/src/monitors/blender.ts +144 -0
  64. package/src/monitors/buildTracker.ts +121 -0
  65. package/src/monitors/collectors.ts +558 -147
  66. package/src/monitors/dns_cache.ts +48 -0
  67. package/src/monitors/index.ts +3 -2
  68. package/src/monitors/ioreport.py +72 -0
  69. package/src/monitors/platform/linux.ts +756 -0
  70. package/src/monitors/platform/windows.ts +519 -0
  71. package/src/monitors/run.ts +27 -3
  72. package/src/monitors/smc.ts +21 -1
  73. package/src/monitors/types.ts +112 -14
  74. package/src/netUsageCmd.ts +121 -0
  75. package/src/platform.test.ts +99 -0
  76. package/src/plugins/index.ts +143 -0
  77. package/src/profileProcCmd.ts +133 -0
  78. package/src/prometheusCmd.ts +107 -0
  79. package/src/replayCmd.ts +186 -0
  80. package/src/serveCmd.ts +180 -0
  81. package/src/service.ts +125 -7
  82. package/src/smartCmd.ts +205 -0
  83. package/src/state/config.ts +64 -17
  84. package/src/syncCmd.ts +166 -0
  85. package/src/topoCmd.ts +176 -0
  86. package/src/watchdogCmd.ts +198 -0
  87. package/src/xbar.ts +22 -11
  88. package/dist/brewHealth-F2BXF5M6.js +0 -74
  89. package/dist/chunk-FH5WDJBF.js +0 -15
  90. package/dist/chunk-HGRBBPYX.js +0 -937
  91. package/dist/doctor-L6FZYA7F.js +0 -184
  92. package/dist/extensions-DVA5YLGH.js +0 -74
  93. package/dist/service-WTS3NBQU.js +0 -73
  94. /package/dist/{chunk-SG26Y3WF.js → chunk-IM3IQMMT.js} +0 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * pyre smart — S.M.A.R.T. disk health inspector.
3
+ *
4
+ * Uses smartctl (from smartmontools, via brew install smartmontools)
5
+ * for detailed SMART data. Falls back to diskutil info when
6
+ * smartctl is not installed.
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import chalk from 'chalk';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ interface DiskInfo {
15
+ device: string;
16
+ model: string;
17
+ serialNumber: string;
18
+ health: 'PASSED' | 'FAILED' | 'Unknown';
19
+ tempC?: number;
20
+ powerOnHours?: number;
21
+ reallocatedSectors?: number;
22
+ percentUsed?: number;
23
+ bytesRead?: number;
24
+ bytesWritten?: number;
25
+ protocol?: string;
26
+ capacity?: string;
27
+ }
28
+
29
+ async function runCmd(cmd: string, args: string[], timeoutMs = 5000): Promise<string> {
30
+ try {
31
+ const { stdout } = await execFileAsync(cmd, args, { timeout: timeoutMs });
32
+ return stdout.trim();
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ async function getPhysicalDisks(): Promise<string[]> {
39
+ // 1. First try smartctl --scan --json on all platforms
40
+ const scanRaw = await runCmd('smartctl', ['--scan', '--json']);
41
+ if (scanRaw) {
42
+ try {
43
+ const parsed = JSON.parse(scanRaw);
44
+ if (Array.isArray(parsed.devices) && parsed.devices.length > 0) {
45
+ return parsed.devices.map((d: any) => d.name).filter(Boolean);
46
+ }
47
+ } catch {
48
+ // ignore
49
+ }
50
+ }
51
+
52
+ // 2. Linux fallback: lsblk
53
+ if (process.platform === 'linux') {
54
+ const lsblk = await runCmd('lsblk', ['-dpno', 'NAME']);
55
+ if (lsblk) {
56
+ const disks = lsblk.split('\n').map(s => s.trim()).filter(d => /^\/dev\/(sd[a-z]|nvme\d+n\d+|vd[a-z])/.test(d));
57
+ if (disks.length > 0) return disks;
58
+ }
59
+ return ['/dev/sda'];
60
+ }
61
+
62
+ // 3. Windows fallback
63
+ if (process.platform === 'win32') {
64
+ return ['/dev/sda', '/dev/csmi0,0'];
65
+ }
66
+
67
+ // 4. macOS fallback: diskutil
68
+ const raw = await runCmd('diskutil', ['list', '-plist']);
69
+ if (!raw) return ['/dev/disk0'];
70
+ const matches = raw.match(/\/dev\/disk\d+(?!s)/g) ?? [];
71
+ return [...new Set(matches)].filter(d => /^\/dev\/disk\d+$/.test(d));
72
+ }
73
+
74
+ async function collectSmartctlDisk(device: string): Promise<DiskInfo | null> {
75
+ const raw = await runCmd('smartctl', ['-a', '--json', device], 6000);
76
+ if (!raw) return null;
77
+ try {
78
+ const j = JSON.parse(raw);
79
+ const model = j.model_name ?? j.device?.name ?? 'Unknown';
80
+ const serial = j.serial_number ?? '';
81
+ const protocol = j.device?.protocol ?? '';
82
+ const passed = j.smart_status?.passed;
83
+ const health: DiskInfo['health'] = passed === true ? 'PASSED' : passed === false ? 'FAILED' : 'Unknown';
84
+
85
+ let tempC: number | undefined;
86
+ if (j.temperature?.current !== undefined) tempC = j.temperature.current;
87
+
88
+ const poh = j.power_on_time?.hours;
89
+
90
+ let reallocated: number | undefined;
91
+ if (Array.isArray(j.ata_smart_attributes?.table)) {
92
+ const attr = j.ata_smart_attributes.table.find((a: any) => a.id === 5);
93
+ if (attr) reallocated = attr.raw?.value ?? 0;
94
+ }
95
+
96
+ let percentUsed: number | undefined;
97
+ if (j.nvme_smart_health_information_log?.percentage_used !== undefined) {
98
+ percentUsed = j.nvme_smart_health_information_log.percentage_used;
99
+ }
100
+
101
+ let bytesRead: number | undefined;
102
+ let bytesWritten: number | undefined;
103
+ if (j.nvme_smart_health_information_log) {
104
+ const unitsRead = j.nvme_smart_health_information_log.data_units_read;
105
+ const unitsWritten = j.nvme_smart_health_information_log.data_units_written;
106
+ if (unitsRead !== undefined) bytesRead = unitsRead * 512000;
107
+ if (unitsWritten !== undefined) bytesWritten = unitsWritten * 512000;
108
+ }
109
+
110
+ const userCap = j.user_capacity?.bytes;
111
+ const capacity = userCap !== undefined
112
+ ? userCap >= 1e12 ? `${(userCap / 1e12).toFixed(1)} TB`
113
+ : userCap >= 1e9 ? `${(userCap / 1e9).toFixed(0)} GB`
114
+ : `${(userCap / 1e6).toFixed(0)} MB`
115
+ : undefined;
116
+
117
+ return { device, model, serialNumber: serial, health, tempC, powerOnHours: poh,
118
+ reallocatedSectors: reallocated, percentUsed, bytesRead, bytesWritten, protocol, capacity };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function collectDiskutilFallback(device: string): Promise<DiskInfo | null> {
125
+ const raw = await runCmd('diskutil', ['info', device]);
126
+ if (!raw) return null;
127
+ const nameMatch = raw.match(/(?:Device \/ Media Name|Volume Name|Media Name):\s*(.+)/i);
128
+ const sizeMatch = raw.match(/Disk Size:\s*(.+?\([^)]+\))/i);
129
+ return {
130
+ device,
131
+ model: nameMatch?.[1]?.trim() || 'Unknown',
132
+ serialNumber: '',
133
+ health: 'Unknown',
134
+ capacity: sizeMatch?.[1]?.trim(),
135
+ };
136
+ }
137
+
138
+ function formatBytes(bytes: number): string {
139
+ if (bytes >= 1e15) return `${(bytes / 1e15).toFixed(1)} PB`;
140
+ if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1)} TB`;
141
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
142
+ if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`;
143
+ return `${bytes} B`;
144
+ }
145
+
146
+ function healthColor(h: DiskInfo['health']): string {
147
+ if (h === 'PASSED') return chalk.green(h);
148
+ if (h === 'FAILED') return chalk.red.bold(h);
149
+ return chalk.dim(h);
150
+ }
151
+
152
+ export async function runSmartCommand(): Promise<void> {
153
+ console.log(chalk.bold('\n 🔥 pyre — S.M.A.R.T. Disk Health\n'));
154
+
155
+ const smartctlVersion = await runCmd('smartctl', ['--version'], 2000);
156
+ const hasSmartctl = smartctlVersion.includes('smartmontools');
157
+
158
+ if (!hasSmartctl) {
159
+ console.log(chalk.yellow(' ⚠ smartctl not found. Showing basic disk info.'));
160
+ console.log(chalk.dim(' For full SMART data: ') + chalk.cyan('brew install smartmontools') + '\n');
161
+ }
162
+
163
+ const devices = await getPhysicalDisks();
164
+ if (devices.length === 0) {
165
+ console.log(chalk.yellow(' No physical disks found.\n'));
166
+ return;
167
+ }
168
+
169
+ for (const device of devices) {
170
+ let info: DiskInfo | null = null;
171
+ if (hasSmartctl) info = await collectSmartctlDisk(device);
172
+ if (!info) info = await collectDiskutilFallback(device);
173
+ if (!info) continue;
174
+
175
+ console.log(chalk.bold(` ${info.device}`));
176
+ console.log(` Model: ${info.model}`);
177
+ if (info.serialNumber) console.log(` Serial: ${chalk.dim(info.serialNumber)}`);
178
+ if (info.protocol) console.log(` Protocol: ${info.protocol}`);
179
+ if (info.capacity) console.log(` Capacity: ${info.capacity}`);
180
+ console.log(` Health: ${healthColor(info.health)}`);
181
+ if (info.tempC !== undefined) {
182
+ const tc = info.tempC >= 60 ? chalk.red : info.tempC >= 45 ? chalk.yellow : chalk.green;
183
+ console.log(` Temperature: ${tc(`${info.tempC}°C`)}`);
184
+ }
185
+ if (info.powerOnHours !== undefined) {
186
+ const days = Math.floor(info.powerOnHours / 24);
187
+ console.log(` Power-On Hours: ${info.powerOnHours.toLocaleString()} h (${days} days)`);
188
+ }
189
+ if (info.reallocatedSectors !== undefined) {
190
+ const sc = info.reallocatedSectors > 0 ? chalk.yellow : chalk.green;
191
+ console.log(` Reallocated Secs: ${sc(info.reallocatedSectors.toString())}`);
192
+ }
193
+ if (info.percentUsed !== undefined) {
194
+ const wc = info.percentUsed >= 90 ? chalk.red : info.percentUsed >= 50 ? chalk.yellow : chalk.green;
195
+ console.log(` Wear Level: ${wc(`${info.percentUsed}% used`)}`);
196
+ }
197
+ if (info.bytesRead !== undefined) console.log(` Data Read: ${formatBytes(info.bytesRead)}`);
198
+ if (info.bytesWritten !== undefined) console.log(` Data Written: ${formatBytes(info.bytesWritten)}`);
199
+ console.log();
200
+ }
201
+
202
+ if (!hasSmartctl) {
203
+ console.log(chalk.dim(' Install smartmontools for full SMART attributes.\n'));
204
+ }
205
+ }
@@ -13,8 +13,6 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
 
16
- const CONFIG_DIR = path.join(os.homedir(), '.config', 'pyre');
17
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
18
16
 
19
17
  export interface PyreConfig {
20
18
  theme?: string;
@@ -41,6 +39,23 @@ export interface PyreConfig {
41
39
  watchdogMem?: number;
42
40
  webhookUrl?: string;
43
41
  alertCmd?: string;
42
+ /** Slack incoming webhook URL for alert notifications */
43
+ slackAlertUrl?: string;
44
+ /** Discord webhook URL for alert notifications */
45
+ discordAlertUrl?: string;
46
+ /** Pushover application token for alert notifications */
47
+ pushoverToken?: string;
48
+ /** Pushover user key for alert notifications */
49
+ pushoverUser?: string;
50
+ /** ntfy.sh topic URL (e.g. https://ntfy.sh/my-pyre-alerts) for alert notifications */
51
+ ntfyUrl?: string;
52
+ /** AI backend: 'builtin' (heuristic / rule-based), 'ollama', or 'openai' */
53
+ aiBackend?: string;
54
+ /** Selected AI model identifier */
55
+ aiModel?: string;
56
+ /** Optional API key for external cloud providers */
57
+ aiApiKey?: string;
58
+ dockerModeConfirmed?: boolean;
44
59
  visiblePanels?: {
45
60
  cpu?: boolean;
46
61
  mem?: boolean;
@@ -53,6 +68,8 @@ export interface PyreConfig {
53
68
  tasks?: boolean;
54
69
  disk?: boolean;
55
70
  process?: boolean;
71
+ containers?: boolean;
72
+ blender?: boolean;
56
73
  tree?: boolean;
57
74
  };
58
75
  panelLayout?: string[];
@@ -83,6 +100,15 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
83
100
  splashAnimation: 'classic',
84
101
  webhookUrl: '',
85
102
  alertCmd: '',
103
+ slackAlertUrl: '',
104
+ discordAlertUrl: '',
105
+ pushoverToken: '',
106
+ pushoverUser: '',
107
+ ntfyUrl: '',
108
+ aiBackend: 'builtin',
109
+ aiModel: 'expert-rules-v1',
110
+ aiApiKey: '',
111
+ dockerModeConfirmed: false,
86
112
  visiblePanels: {
87
113
  cpu: true,
88
114
  mem: true,
@@ -95,15 +121,34 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
95
121
  tasks: true,
96
122
  disk: true,
97
123
  process: true,
124
+ containers: true,
125
+ blender: true,
98
126
  tree: false,
99
127
  },
100
128
  panelLayout: ['mem', 'disk', 'net'],
101
129
  };
102
130
 
131
+ export function getConfigDir(): string {
132
+ return process.env.PYRE_CONFIG_DIR || path.join(os.homedir(), '.config', 'pyre');
133
+ }
134
+
135
+ export function getConfigFile(): string {
136
+ return path.join(getConfigDir(), 'config.json');
137
+ }
138
+
139
+ export function getProfilesDir(): string {
140
+ return path.join(getConfigDir(), 'profiles');
141
+ }
142
+
143
+ export const CONFIG_DIR = getConfigDir();
144
+ export const CONFIG_FILE = getConfigFile();
145
+ export const PROFILES_DIR = getProfilesDir();
146
+
103
147
  export function readConfig(): Required<PyreConfig> {
104
148
  try {
105
- if (!fs.existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
106
- const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
149
+ const file = getConfigFile();
150
+ if (!fs.existsSync(file)) return { ...DEFAULT_CONFIG };
151
+ const raw = fs.readFileSync(file, 'utf-8');
107
152
  const parsed = JSON.parse(raw) as Partial<PyreConfig>;
108
153
  return deepMerge(DEFAULT_CONFIG, parsed);
109
154
  } catch {
@@ -113,32 +158,34 @@ export function readConfig(): Required<PyreConfig> {
113
158
 
114
159
  export function writeConfig(config: Partial<PyreConfig>): void {
115
160
  try {
116
- if (!fs.existsSync(CONFIG_DIR)) {
117
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
161
+ const dir = getConfigDir();
162
+ const file = getConfigFile();
163
+ if (!fs.existsSync(dir)) {
164
+ fs.mkdirSync(dir, { recursive: true });
118
165
  }
119
166
  const merged = deepMerge(readConfig(), config);
120
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
167
+ fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
121
168
  } catch {
122
169
  // ignore write errors
123
170
  }
124
171
  }
125
172
 
126
- const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles');
127
-
128
173
  export function saveProfile(name: string): void {
129
174
  if (!name || name.includes('/') || name.includes('\\')) {
130
175
  throw new Error('Invalid profile name.');
131
176
  }
132
- if (!fs.existsSync(PROFILES_DIR)) {
133
- fs.mkdirSync(PROFILES_DIR, { recursive: true });
177
+ const profilesDir = getProfilesDir();
178
+ if (!fs.existsSync(profilesDir)) {
179
+ fs.mkdirSync(profilesDir, { recursive: true });
134
180
  }
135
181
  const currentConfig = readConfig();
136
- const profileFile = path.join(PROFILES_DIR, `${name}.json`);
182
+ const profileFile = path.join(profilesDir, `${name}.json`);
137
183
  fs.writeFileSync(profileFile, JSON.stringify(currentConfig, null, 2) + '\n');
138
184
  }
139
185
 
140
186
  export function loadProfile(name: string): void {
141
- const profileFile = path.join(PROFILES_DIR, `${name}.json`);
187
+ const profilesDir = getProfilesDir();
188
+ const profileFile = path.join(profilesDir, `${name}.json`);
142
189
  if (!fs.existsSync(profileFile)) {
143
190
  throw new Error(`Profile '${name}' does not exist.`);
144
191
  }
@@ -149,8 +196,9 @@ export function loadProfile(name: string): void {
149
196
  }
150
197
 
151
198
  export function listProfiles(): string[] {
152
- if (!fs.existsSync(PROFILES_DIR)) return [];
153
- const files = fs.readdirSync(PROFILES_DIR);
199
+ const profilesDir = getProfilesDir();
200
+ if (!fs.existsSync(profilesDir)) return [];
201
+ const files = fs.readdirSync(profilesDir);
154
202
  return files.filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
155
203
  }
156
204
 
@@ -169,7 +217,6 @@ function deepMerge(target: any, source: any): any {
169
217
  }
170
218
 
171
219
  export function getConfigPath(): string {
172
- return CONFIG_FILE;
220
+ return getConfigFile();
173
221
  }
174
222
 
175
- export { CONFIG_FILE, PROFILES_DIR };
package/src/syncCmd.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * pyre sync — Cloud Configuration & Anomaly History Sync.
3
+ *
4
+ * Synchronizes configuration profiles (~/.config/pyre/profiles/),
5
+ * alert rules, thresholds, and anomaly history across multiple Macs
6
+ * via iCloud Drive or any user-defined sync folder.
7
+ *
8
+ * Commands:
9
+ * pyre sync status
10
+ * pyre sync push
11
+ * pyre sync pull
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import crypto from 'node:crypto';
17
+ import chalk from 'chalk';
18
+ import { getConfigDir, getConfigFile, getProfilesDir, readConfig, writeConfig } from './state/config.js';
19
+
20
+ export interface SyncOptions {
21
+ syncDir?: string;
22
+ force?: boolean;
23
+ }
24
+
25
+ interface SyncManifest {
26
+ version: number;
27
+ lastUpdated: string;
28
+ sourceHost: string;
29
+ checksum: string;
30
+ profilesCount: number;
31
+ }
32
+
33
+ function getDefaultSyncDir(): string {
34
+ if (process.env.PYRE_SYNC_DIR) return process.env.PYRE_SYNC_DIR;
35
+
36
+ // macOS native iCloud Drive path
37
+ const icloudPath = path.join(os.homedir(), 'Library', 'Mobile Documents', 'com~apple~CloudDocs', 'PyreSync');
38
+ if (fs.existsSync(path.dirname(icloudPath))) {
39
+ return icloudPath;
40
+ }
41
+
42
+ // Fallback to cross-platform standard shared folder
43
+ return path.join(os.homedir(), '.pyre-sync');
44
+ }
45
+
46
+ function computeDirChecksum(dirPath: string): string {
47
+ const hash = crypto.createHash('sha256');
48
+ if (!fs.existsSync(dirPath)) return '';
49
+
50
+ const files = fs.readdirSync(dirPath).sort();
51
+ for (const file of files) {
52
+ const full = path.join(dirPath, file);
53
+ if (fs.statSync(full).isFile()) {
54
+ hash.update(file);
55
+ hash.update(fs.readFileSync(full));
56
+ }
57
+ }
58
+ return hash.digest('hex').slice(0, 16);
59
+ }
60
+
61
+ export async function runSyncCommand(subcommand: string = 'status', opts: SyncOptions = {}): Promise<void> {
62
+ const syncDir = opts.syncDir || getDefaultSyncDir();
63
+ const manifestPath = path.join(syncDir, 'sync-manifest.json');
64
+ const remoteProfilesDir = path.join(syncDir, 'profiles');
65
+ const remoteConfigFile = path.join(syncDir, 'config.json');
66
+
67
+ const localProfilesDir = getProfilesDir();
68
+ const localConfigFile = getConfigFile();
69
+
70
+ console.log(chalk.bold('\n 🔥 pyre sync — Configuration & Profile Cloud Sync\n'));
71
+ console.log(` Sync Target Directory: ${chalk.cyan(syncDir)}`);
72
+
73
+ if (subcommand === 'status' || !subcommand) {
74
+ const hasSyncDir = fs.existsSync(syncDir);
75
+ const hasManifest = fs.existsSync(manifestPath);
76
+
77
+ console.log(` Local Profiles: ${chalk.bold(String(fs.existsSync(localProfilesDir) ? fs.readdirSync(localProfilesDir).filter(f => f.endsWith('.json')).length : 0))}`);
78
+ console.log(` Sync Target Available: ${hasSyncDir ? chalk.green('YES') : chalk.yellow('NO (will be created on push)')}`);
79
+
80
+ if (hasManifest) {
81
+ try {
82
+ const manifest: SyncManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
83
+ console.log(` Remote Last Updated: ${chalk.green(manifest.lastUpdated)} (from ${chalk.bold(manifest.sourceHost)})`);
84
+ console.log(` Remote Profiles Count: ${chalk.bold(String(manifest.profilesCount))}`);
85
+ console.log(` Remote Checksum: ${chalk.dim(manifest.checksum)}`);
86
+ } catch {
87
+ console.log(chalk.yellow(' Remote manifest corrupted.'));
88
+ }
89
+ } else {
90
+ console.log(chalk.dim(' No remote sync bundle detected yet. Run "pyre sync push" to initialize.'));
91
+ }
92
+ console.log();
93
+ return;
94
+ }
95
+
96
+ if (subcommand === 'push') {
97
+ if (!fs.existsSync(syncDir)) {
98
+ fs.mkdirSync(syncDir, { recursive: true });
99
+ }
100
+ if (!fs.existsSync(remoteProfilesDir)) {
101
+ fs.mkdirSync(remoteProfilesDir, { recursive: true });
102
+ }
103
+
104
+ // 1. Sync config.json
105
+ if (fs.existsSync(localConfigFile)) {
106
+ fs.copyFileSync(localConfigFile, remoteConfigFile);
107
+ }
108
+
109
+ // 2. Sync profiles/
110
+ let profileCount = 0;
111
+ if (fs.existsSync(localProfilesDir)) {
112
+ const files = fs.readdirSync(localProfilesDir).filter(f => f.endsWith('.json'));
113
+ profileCount = files.length;
114
+ for (const f of files) {
115
+ fs.copyFileSync(path.join(localProfilesDir, f), path.join(remoteProfilesDir, f));
116
+ }
117
+ }
118
+
119
+ // 3. Write manifest
120
+ const checksum = computeDirChecksum(remoteProfilesDir);
121
+ const manifest: SyncManifest = {
122
+ version: 1,
123
+ lastUpdated: new Date().toISOString(),
124
+ sourceHost: os.hostname(),
125
+ checksum,
126
+ profilesCount: profileCount,
127
+ };
128
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
129
+
130
+ console.log(chalk.green(` ✔ Successfully pushed config and ${profileCount} profile(s) to sync target.`));
131
+ console.log(` Checksum: ${chalk.dim(checksum)} | Timestamp: ${chalk.dim(manifest.lastUpdated)}\n`);
132
+ return;
133
+ }
134
+
135
+ if (subcommand === 'pull') {
136
+ if (!fs.existsSync(manifestPath) || !fs.existsSync(remoteConfigFile)) {
137
+ console.error(chalk.red(` ✖ Error: No sync manifest found at ${syncDir}. Run "pyre sync push" first from another machine.\n`));
138
+ process.exit(1);
139
+ }
140
+
141
+ // 1. Pull config.json
142
+ if (fs.existsSync(remoteConfigFile)) {
143
+ const remoteConfig = JSON.parse(fs.readFileSync(remoteConfigFile, 'utf-8'));
144
+ writeConfig(remoteConfig);
145
+ }
146
+
147
+ // 2. Pull profiles
148
+ let pulledCount = 0;
149
+ if (fs.existsSync(remoteProfilesDir)) {
150
+ if (!fs.existsSync(localProfilesDir)) {
151
+ fs.mkdirSync(localProfilesDir, { recursive: true });
152
+ }
153
+ const files = fs.readdirSync(remoteProfilesDir).filter(f => f.endsWith('.json'));
154
+ pulledCount = files.length;
155
+ for (const f of files) {
156
+ fs.copyFileSync(path.join(remoteProfilesDir, f), path.join(localProfilesDir, f));
157
+ }
158
+ }
159
+
160
+ console.log(chalk.green(` ✔ Successfully pulled latest configuration and ${pulledCount} profile(s).`));
161
+ console.log(chalk.dim(` Applied to ${localConfigFile}\n`));
162
+ return;
163
+ }
164
+
165
+ console.log(chalk.yellow(` Unknown subcommand: "${subcommand}". Available: status, push, pull\n`));
166
+ }