@@ -170,23 +170,158 @@ final class PNCommandRunner {
170170
171171 private func runInstall( arguments input: PNArguments ) throws {
172172 var arguments = input
173- let to = try arguments. option ( " --to " ) ?? " /usr/local/bin "
173+ let explicitDestination = try arguments. option ( " --to " )
174+ let installDirectory = defaultInstallDirectory ( )
175+ let to = explicitDestination ?? installDirectory. path
174176 let force = arguments. flag ( " --force " )
175177 try arguments. ensureNoExtras ( )
176178
177179 let source = PNAppBridge . executableURL
178- let targetBase = URL ( fileURLWithPath: to )
180+ let targetBase = URL ( fileURLWithPath: NSString ( string : to ) . expandingTildeInPath )
179181 let target = targetBase. lastPathComponent == " pn " ? targetBase : targetBase. appendingPathComponent ( " pn " , isDirectory: false )
180- if FileManager . default. fileExists ( atPath: target. path) {
181- guard force else {
182+ let existingSymbolicLink = existingSymbolicLinkDestination ( at: target)
183+ let targetExists = FileManager . default. fileExists ( atPath: target. path) || existingSymbolicLink != nil
184+ var didLink = false
185+ if targetExists {
186+ if force {
187+ try FileManager . default. removeItem ( at: target)
188+ }
189+ else if let existingSymbolicLink {
190+ // Relink anything that does not already point at this executable,
191+ // including stale links left by moved or removed app copies.
192+ let destination = symbolicLinkDestinationURL ( existingSymbolicLink, relativeTo: target)
193+ if destination. resolvingSymlinksInPath ( ) . path != source. path {
194+ try FileManager . default. removeItem ( at: target)
195+ }
196+ }
197+ else {
182198 throw PNError . diskError ( " \( target. path) already exists. Re-run with --force to replace it. " )
183199 }
184- try FileManager . default. removeItem ( at: target)
185200 }
186- try FileManager . default. createDirectory ( at: target. deletingLastPathComponent ( ) , withIntermediateDirectories: true )
187- try FileManager . default. createSymbolicLink ( at: target, withDestinationURL: source)
201+ if !FileManager. default. fileExists ( atPath: target. path) && existingSymbolicLinkDestination ( at: target) == nil {
202+ try FileManager . default. createDirectory ( at: target. deletingLastPathComponent ( ) , withIntermediateDirectories: true )
203+ try FileManager . default. createSymbolicLink ( at: target, withDestinationURL: source)
204+ didLink = true
205+ }
206+ let shouldUpdateShellProfile = target. deletingLastPathComponent ( ) . standardizedFileURL. path == installDirectory. standardizedFileURL. path
207+ let didUpdateShellProfile = shouldUpdateShellProfile
208+ ? try ensureZshProfileContainsInstallPath ( installDirectory)
209+ : false
188210 let result = PNInstallResult ( source: source. path, target: target. path)
189- emit ( result, human: " Linked \( target. path) -> \( source. path) " )
211+ var lines = didLink
212+ ? [ " Linked \( target. path) -> \( source. path) " ]
213+ : [ " Link already up to date at \( target. path) -> \( source. path) " ]
214+ if didUpdateShellProfile {
215+ lines. append ( " Updated ~/.zprofile to include \( target. deletingLastPathComponent ( ) . path) in PATH " )
216+ }
217+ emit ( result, human: lines. joined ( separator: " \n " ) )
218+ }
219+
220+ private func defaultInstallDirectory( ) -> URL {
221+ realHomeDirectory ( ) . appendingPathComponent ( " .local/bin " , isDirectory: true )
222+ }
223+
224+ // When spawned from the sandboxed app, HOME points to the app container;
225+ // resolve the user's real home directory instead.
226+ private func realHomeDirectory( ) -> URL {
227+ if let home = getpwuid ( getuid ( ) ) ? . pointee. pw_dir {
228+ return URL ( fileURLWithPath: String ( cString: home) , isDirectory: true )
229+ }
230+ return FileManager . default. homeDirectoryForCurrentUser
231+ }
232+
233+ private func existingSymbolicLinkDestination( at url: URL ) -> String ? {
234+ try ? FileManager . default. destinationOfSymbolicLink ( atPath: url. path)
235+ }
236+
237+ private func symbolicLinkDestinationURL( _ destination: String , relativeTo linkURL: URL ) -> URL {
238+ let destinationURL = destination. hasPrefix ( " / " )
239+ ? URL ( fileURLWithPath: destination)
240+ : linkURL. deletingLastPathComponent ( ) . appendingPathComponent ( destination)
241+ return destinationURL. standardizedFileURL
242+ }
243+
244+ private func ensureZshProfileContainsInstallPath( _ installDirectory: URL ) throws -> Bool {
245+ if try zshPATHContainsInstallDirectory ( installDirectory) {
246+ return false
247+ }
248+
249+ let profileURL = realHomeDirectory ( )
250+ . appendingPathComponent ( " .zprofile " , isDirectory: false )
251+ let commentLine = " # Added by Planet to make the pn CLI available in Terminal. "
252+ let exportLine = #"export PATH="$HOME/.local/bin:$PATH""#
253+ var contents = " "
254+
255+ if FileManager . default. fileExists ( atPath: profileURL. path) {
256+ contents = try String ( contentsOf: profileURL, encoding: . utf8)
257+ if contents. contains ( exportLine) {
258+ return false
259+ }
260+ }
261+
262+ if !contents. isEmpty && !contents. hasSuffix ( " \n " ) {
263+ contents. append ( " \n " )
264+ }
265+ if !contents. isEmpty {
266+ contents. append ( " \n " )
267+ }
268+ contents. append ( commentLine)
269+ contents. append ( " \n " )
270+ contents. append ( exportLine)
271+ contents. append ( " \n " )
272+
273+ try contents. write ( to: profileURL, atomically: true , encoding: . utf8)
274+ return true
275+ }
276+
277+ private func zshPATHContainsInstallDirectory( _ installDirectory: URL ) throws -> Bool {
278+ let process = Process ( )
279+ let outputPipe = Pipe ( )
280+ let errorPipe = Pipe ( )
281+ let pathMarkerStart = " __PLANET_PN_PATH_START__ "
282+ let pathMarkerEnd = " __PLANET_PN_PATH_END__ "
283+ process. executableURL = URL ( fileURLWithPath: " /bin/zsh " )
284+ process. arguments = [
285+ " -lic " ,
286+ #"printf "\n__PLANET_PN_PATH_START__%s__PLANET_PN_PATH_END__\n" "$PATH""# ,
287+ ]
288+ process. environment = [
289+ " HOME " : realHomeDirectory ( ) . path,
290+ " LOGNAME " : NSUserName ( ) ,
291+ " PATH " : " /usr/bin:/bin:/usr/sbin:/sbin " ,
292+ " SHELL " : " /bin/zsh " ,
293+ " USER " : NSUserName ( ) ,
294+ ]
295+ process. standardOutput = outputPipe
296+ process. standardError = errorPipe
297+
298+ try process. run ( )
299+ process. waitUntilExit ( )
300+
301+ let outputData = outputPipe. fileHandleForReading. readDataToEndOfFile ( )
302+ let errorData = errorPipe. fileHandleForReading. readDataToEndOfFile ( )
303+ let output = String ( data: outputData, encoding: . utf8) ?? " "
304+ guard process. terminationStatus == 0 else {
305+ let error = String ( data: errorData, encoding: . utf8) ?
306+ . trimmingCharacters ( in: . whitespacesAndNewlines)
307+ throw PNError . diskError ( error? . pnNilIfEmpty ?? output. trimmingCharacters ( in: . whitespacesAndNewlines) )
308+ }
309+ guard
310+ let pathStartRange = output. range ( of: pathMarkerStart) ,
311+ let pathEndRange = output. range ( of: pathMarkerEnd, range: pathStartRange. upperBound..< output. endIndex)
312+ else {
313+ throw PNError . diskError ( " Could not read shell PATH. " )
314+ }
315+ let path = String ( output [ pathStartRange. upperBound..< pathEndRange. lowerBound] )
316+ return pathList ( path, contains: installDirectory)
317+ }
318+
319+ private func pathList( _ pathList: String , contains targetURL: URL ) -> Bool {
320+ let targetPath = targetURL. standardizedFileURL. path
321+ return pathList. split ( separator: " : " ) . contains { item in
322+ let expandedPath = NSString ( string: String ( item) ) . expandingTildeInPath
323+ return URL ( fileURLWithPath: expandedPath) . standardizedFileURL. path == targetPath
324+ }
190325 }
191326
192327 private func runStatus( ) throws {
@@ -880,7 +1015,7 @@ final class PNCommandRunner {
8801015 Commands:
8811016 help [command]
8821017 version
883- install [--to /usr/ local/bin] [--force]
1018+ install [--to ~/. local/bin] [--force]
8841019 status
8851020 api status
8861021 api start [--port 8086] [--wait 10]
0 commit comments