@stacksjs/rpx 0.11.3 → 0.11.5

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/src/hosts.ts ADDED
@@ -0,0 +1,257 @@
1
+ import { exec } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import * as process from 'node:process'
6
+ import { promisify } from 'node:util'
7
+ import { debugLog, getSudoPassword } from './utils'
8
+
9
+ const execAsync = promisify(exec)
10
+
11
+ export const hostsFilePath: string = process.platform === 'win32'
12
+ ? path.join(process.env.windir || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
13
+ : '/etc/hosts'
14
+
15
+ // Flag to track if we've already received sudo privileges in this session
16
+ let sudoPrivilegesAcquired = false
17
+
18
+ // Single function to execute sudo commands, with caching for permissions.
19
+ // Wraps in sh -c so pipes/redirects all run under sudo.
20
+ async function execSudo(command: string): Promise<string> {
21
+ if (process.platform === 'win32')
22
+ throw new Error('Administrator privileges required on Windows')
23
+
24
+ const sudoPassword = getSudoPassword()
25
+ const escaped = command.replace(/'/g, `'\\''`)
26
+
27
+ try {
28
+ if (sudoPassword) {
29
+ const { stdout } = await execAsync(`echo '${sudoPassword}' | sudo -S sh -c '${escaped}' 2>/dev/null`)
30
+ sudoPrivilegesAcquired = true
31
+ return stdout
32
+ }
33
+
34
+ if (sudoPrivilegesAcquired) {
35
+ try {
36
+ const { stdout } = await execAsync(`sudo -n sh -c '${escaped}'`)
37
+ return stdout
38
+ }
39
+ // eslint-disable-next-line unused-imports/no-unused-vars
40
+ catch (error) {
41
+ debugLog('hosts', 'Cached sudo privileges expired, requesting again', true)
42
+ }
43
+ }
44
+
45
+ const { stdout } = await execAsync(`sudo sh -c '${escaped}'`)
46
+ sudoPrivilegesAcquired = true
47
+ return stdout
48
+ }
49
+ catch (error) {
50
+ throw new Error(`Failed to execute sudo command: ${(error as Error).message}`)
51
+ }
52
+ }
53
+
54
+ export async function addHosts(hosts: string[], verbose?: boolean): Promise<void> {
55
+ debugLog('hosts', `Adding hosts: ${hosts.join(', ')}`, verbose)
56
+ debugLog('hosts', `Using hosts file at: ${hostsFilePath}`, verbose)
57
+
58
+ try {
59
+ // Read existing hosts file content
60
+ let existingContent: string
61
+ try {
62
+ existingContent = await fs.promises.readFile(hostsFilePath, 'utf-8')
63
+ }
64
+ catch {
65
+ // /etc/hosts typically requires elevated permissions — fall back to sudo
66
+ debugLog('hosts', 'Reading hosts file requires elevated permissions, using sudo', verbose)
67
+
68
+ try {
69
+ existingContent = await execSudo(`cat "${hostsFilePath}"`)
70
+ }
71
+ catch (sudoErr) {
72
+ console.log(' Could not read hosts file — skipping hosts setup')
73
+ debugLog('hosts', `sudo read also failed: ${sudoErr}`, verbose)
74
+ throw new Error(`Cannot read hosts file: ${sudoErr}`)
75
+ }
76
+ }
77
+
78
+ // Prepare new entries, only including those that don't exist
79
+ const newEntries = hosts.filter((host) => {
80
+ const ipv4Entry = `127.0.0.1 ${host}`
81
+ const ipv6Entry = `::1 ${host}`
82
+ return !existingContent.includes(ipv4Entry) && !existingContent.includes(ipv6Entry)
83
+ })
84
+
85
+ if (newEntries.length === 0) {
86
+ debugLog('hosts', 'All hosts already exist in hosts file', verbose)
87
+ return
88
+ }
89
+
90
+ // Create content for new entries
91
+ const hostEntries = newEntries.map(host =>
92
+ `\n# Added by rpx\n127.0.0.1 ${host}\n::1 ${host}`,
93
+ ).join('\n')
94
+
95
+ const tmpFile = path.join(os.tmpdir(), `rpx-hosts-${Date.now()}.tmp`)
96
+
97
+ try {
98
+ // Write to temporary file
99
+ await fs.promises.writeFile(tmpFile, existingContent + hostEntries, 'utf8')
100
+
101
+ // Use tee with sudo to write the content to hosts file
102
+ await execSudo(`cat "${tmpFile}" | tee "${hostsFilePath}" > /dev/null`)
103
+ console.log(` Hosts updated: ${newEntries.join(', ')}`)
104
+ }
105
+ // eslint-disable-next-line unused-imports/no-unused-vars
106
+ catch (error) {
107
+ // Don't throw — just tell the user what to add manually
108
+ console.log(' Could not update hosts file automatically')
109
+ console.log(' Add these entries to /etc/hosts:')
110
+ newEntries.forEach((host) => {
111
+ console.log(` 127.0.0.1 ${host}`)
112
+ console.log(` ::1 ${host}`)
113
+ })
114
+ console.log(` Or run: sudo nano ${hostsFilePath}`)
115
+ }
116
+ finally {
117
+ try {
118
+ await fs.promises.unlink(tmpFile)
119
+ }
120
+ catch {
121
+ // Ignore cleanup errors
122
+ }
123
+ }
124
+ }
125
+ catch (err) {
126
+ const error = err as Error
127
+ debugLog('hosts', `Failed to manage hosts file: ${error.message}`, verbose)
128
+ // Don't throw - hosts file management is best-effort
129
+ }
130
+ }
131
+
132
+ export async function removeHosts(hosts: string[], verbose?: boolean): Promise<void> {
133
+ debugLog('hosts', `Removing hosts: ${hosts.join(', ')}`, verbose)
134
+
135
+ try {
136
+ // Read existing hosts file content
137
+ let content: string
138
+ try {
139
+ content = await fs.promises.readFile(hostsFilePath, 'utf-8')
140
+ }
141
+ catch {
142
+ debugLog('hosts', 'Reading hosts file requires elevated permissions, using sudo', verbose)
143
+
144
+ try {
145
+ content = await execSudo(`cat "${hostsFilePath}"`)
146
+ }
147
+ catch (sudoErr) {
148
+ debugLog('hosts', `sudo read also failed: ${sudoErr}`, verbose)
149
+ throw new Error(`Cannot read hosts file: ${sudoErr}`)
150
+ }
151
+ }
152
+
153
+ const lines = content.split('\n')
154
+ let modified = false
155
+
156
+ // Filter out our added entries and their comments
157
+ const filteredLines = lines.filter((line) => {
158
+ // Check if this line contains one of our hosts
159
+ const isHostLine = hosts.some(host =>
160
+ line.includes(` ${host}`)
161
+ && (line.includes('127.0.0.1') || line.includes('::1')),
162
+ )
163
+
164
+ if (isHostLine) {
165
+ modified = true
166
+ return false
167
+ }
168
+
169
+ // If it's our comment line, remove it
170
+ if (line.trim() === '# Added by rpx') {
171
+ modified = true
172
+ return false
173
+ }
174
+
175
+ return true
176
+ })
177
+
178
+ // If nothing was removed, we're done
179
+ if (!modified) {
180
+ debugLog('hosts', 'No matching hosts found to remove', verbose)
181
+ return
182
+ }
183
+
184
+ // Remove empty lines at the end of the file
185
+ while (filteredLines[filteredLines.length - 1]?.trim() === '')
186
+ filteredLines.pop()
187
+
188
+ // Ensure file ends with a single newline
189
+ const newContent = `${filteredLines.join('\n')}\n`
190
+
191
+ const tmpFile = path.join(os.tmpdir(), `rpx-hosts-${Date.now()}.tmp`)
192
+
193
+ try {
194
+ // Write to temporary file
195
+ await fs.promises.writeFile(tmpFile, newContent, 'utf8')
196
+
197
+ // Use tee with sudo to write the content to hosts file
198
+ await execSudo(`cat "${tmpFile}" | tee "${hostsFilePath}" > /dev/null`)
199
+ debugLog('hosts', 'Hosts removed successfully', verbose)
200
+ }
201
+ // eslint-disable-next-line unused-imports/no-unused-vars
202
+ catch (error) {
203
+ debugLog('hosts', 'Could not clean up hosts file automatically', verbose)
204
+ }
205
+ finally {
206
+ try {
207
+ // Clean up the temp file
208
+ await fs.promises.unlink(tmpFile)
209
+ }
210
+ catch (unlinkErr) {
211
+ // Ignore cleanup errors
212
+ debugLog('hosts', `Failed to remove temporary file: ${unlinkErr}`, verbose)
213
+ }
214
+ }
215
+ }
216
+ catch (err) {
217
+ debugLog('hosts', `Failed to clean up hosts file: ${(err as Error).message}`, verbose)
218
+ // Don't throw - hosts file cleanup is best-effort
219
+ }
220
+ }
221
+
222
+ export async function checkHosts(hosts: string[], verbose?: boolean): Promise<boolean[]> {
223
+ debugLog('hosts', `Checking hosts: ${hosts}`, verbose)
224
+
225
+ let content: string
226
+ try {
227
+ content = await fs.promises.readFile(hostsFilePath, 'utf-8')
228
+ }
229
+ catch (readErr) {
230
+ debugLog('hosts', `Error reading hosts file: ${readErr}`, verbose)
231
+
232
+ // Try with sudo using SUDO_PASSWORD if available
233
+ try {
234
+ const sudoPassword = getSudoPassword()
235
+ let cmd: string
236
+ if (sudoPassword) {
237
+ cmd = `echo '${sudoPassword}' | sudo -S cat "${hostsFilePath}" 2>/dev/null`
238
+ }
239
+ else {
240
+ cmd = `sudo -n cat "${hostsFilePath}" 2>/dev/null || cat "${hostsFilePath}" 2>/dev/null || echo ""`
241
+ }
242
+ const { stdout } = await execAsync(cmd)
243
+ content = stdout
244
+ }
245
+ catch (sudoErr) {
246
+ // Can't read hosts file - assume entries don't exist
247
+ debugLog('hosts', `Cannot read hosts file, assuming entries don't exist: ${sudoErr}`, verbose)
248
+ return hosts.map(() => false)
249
+ }
250
+ }
251
+
252
+ return hosts.map((host) => {
253
+ const ipv4Entry = `127.0.0.1 ${host}`
254
+ const ipv6Entry = `::1 ${host}`
255
+ return content.includes(ipv4Entry) || content.includes(ipv6Entry)
256
+ })
257
+ }