Skip to content

Commit 4f93e9b

Browse files
lividclaude
andcommitted
Add Tools -> Install CLI for installing pn into ~/.local/bin
pn install now defaults to ~/.local/bin, relinks stale symlinks that point at other executables, and appends a ~/.zprofile PATH entry when the login zsh PATH lacks the install directory. The app menu action resolves the user's real home via getpwuid and launches the bundled helper from Contents/Helpers under new sandbox exceptions for ~/.local and ~/.zprofile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 187ff18 commit 4f93e9b

6 files changed

Lines changed: 317 additions & 14 deletions

File tree

Planet/Helper/KeyboardShortcutHelper.swift

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ class KeyboardShortcutHelper: ObservableObject {
204204

205205
apiConsoleMenus()
206206

207+
Button {
208+
self.installCLIAction()
209+
} label: {
210+
Text("Install CLI")
211+
}
212+
207213
Button {
208214
AppLogWindowManager.shared.open()
209215
} label: {
@@ -329,6 +335,138 @@ class KeyboardShortcutHelper: ObservableObject {
329335
}
330336
}
331337

338+
private func installCLIAction() {
339+
if installedCLILinksToBundledCLI {
340+
do {
341+
try runCLIInstall()
342+
presentCLIAlreadyInstalledAlert()
343+
}
344+
catch {
345+
presentCLIInstallError(error)
346+
}
347+
return
348+
}
349+
350+
let alert = NSAlert()
351+
alert.messageText = L10n("Install CLI")
352+
alert.informativeText = L10n("Install the pn CLI to ~/.local/bin so it becomes available in Terminal?")
353+
alert.alertStyle = .informational
354+
alert.addButton(withTitle: L10n("Install"))
355+
alert.addButton(withTitle: L10n("Cancel"))
356+
357+
guard alert.runModal() == .alertFirstButtonReturn else { return }
358+
359+
do {
360+
try runCLIInstall()
361+
presentCLIInstalledAlert(
362+
messageText: L10n("CLI Installed"),
363+
informativeText: L10n("A symbolic link for pn has been installed at ~/.local/bin/pn and can be used in Terminal.")
364+
)
365+
}
366+
catch {
367+
presentCLIInstallError(error)
368+
}
369+
}
370+
371+
private var installedCLILinksToBundledCLI: Bool {
372+
guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: cliInstallURL.path) else {
373+
return false
374+
}
375+
let destinationURL = destination.hasPrefix("/")
376+
? URL(fileURLWithPath: destination)
377+
: cliInstallURL.deletingLastPathComponent().appendingPathComponent(destination)
378+
return destinationURL.standardizedFileURL.resolvingSymlinksInPath().path
379+
== bundledCLIURL.resolvingSymlinksInPath().path
380+
}
381+
382+
private var cliInstallDirectory: URL {
383+
cliRealHomeDirectory.appendingPathComponent(".local/bin", isDirectory: true)
384+
}
385+
386+
// In the sandbox, homeDirectoryForCurrentUser points to the app container;
387+
// the CLI must be installed relative to the user's real home directory.
388+
private var cliRealHomeDirectory: URL {
389+
if let home = getpwuid(getuid())?.pointee.pw_dir {
390+
return URL(fileURLWithPath: String(cString: home), isDirectory: true)
391+
}
392+
return FileManager.default.homeDirectoryForCurrentUser
393+
}
394+
395+
private var cliInstallURL: URL {
396+
cliInstallDirectory.appendingPathComponent("pn", isDirectory: false)
397+
}
398+
399+
// pn is embedded in Contents/Helpers, which url(forAuxiliaryExecutable:)
400+
// does not search.
401+
private var bundledCLIURL: URL {
402+
Bundle.main.bundleURL
403+
.appendingPathComponent("Contents/Helpers/pn", isDirectory: false)
404+
}
405+
406+
private func runCLIInstall() throws {
407+
let cliURL = bundledCLIURL
408+
guard FileManager.default.isExecutableFile(atPath: cliURL.path) else {
409+
throw CLIInstallError.helperNotFound
410+
}
411+
412+
let process = Process()
413+
let outputPipe = Pipe()
414+
process.executableURL = cliURL
415+
process.arguments = ["install", "--to", cliInstallDirectory.path]
416+
process.standardOutput = outputPipe
417+
process.standardError = outputPipe
418+
419+
try process.run()
420+
process.waitUntilExit()
421+
422+
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
423+
let output = String(data: outputData, encoding: .utf8)?
424+
.trimmingCharacters(in: .whitespacesAndNewlines)
425+
426+
guard process.terminationStatus == 0 else {
427+
throw CLIInstallError.installFailed(output ?? L10n("pn install failed."))
428+
}
429+
}
430+
431+
private func presentCLIAlreadyInstalledAlert() {
432+
presentCLIInstalledAlert(
433+
messageText: L10n("CLI Already Installed"),
434+
informativeText: L10n("A symbolic link for pn is already installed at ~/.local/bin/pn and can be used in Terminal.")
435+
)
436+
}
437+
438+
private func presentCLIInstallError(_ error: Error) {
439+
let alert = NSAlert()
440+
alert.messageText = L10n("Failed to Install CLI")
441+
alert.informativeText = error.localizedDescription
442+
alert.alertStyle = .warning
443+
alert.addButton(withTitle: L10n("OK"))
444+
alert.runModal()
445+
}
446+
447+
private func presentCLIInstalledAlert(messageText: String, informativeText: String) {
448+
let alert = NSAlert()
449+
alert.messageText = messageText
450+
alert.informativeText = informativeText
451+
alert.alertStyle = .informational
452+
alert.addButton(withTitle: L10n("OK"))
453+
alert.addButton(withTitle: L10n("Open Terminal"))
454+
455+
if alert.runModal() == .alertSecondButtonReturn {
456+
openTerminal()
457+
}
458+
}
459+
460+
private func openTerminal() {
461+
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal") else {
462+
return
463+
}
464+
465+
let configuration = NSWorkspace.OpenConfiguration()
466+
configuration.activates = true
467+
NSWorkspace.shared.openApplication(at: appURL, configuration: configuration, completionHandler: nil)
468+
}
469+
332470
// MARK: -
333471

334472
private func importArticleAction() {
@@ -507,3 +645,17 @@ class KeyboardShortcutHelper: ObservableObject {
507645
PlanetAPIConsoleWindowManager.shared.consoleCommandMenu()
508646
}
509647
}
648+
649+
private enum CLIInstallError: LocalizedError {
650+
case helperNotFound
651+
case installFailed(String)
652+
653+
var errorDescription: String? {
654+
switch self {
655+
case .helperNotFound:
656+
return L10n("The bundled pn helper could not be found.")
657+
case .installFailed(let output):
658+
return output.isEmpty ? L10n("pn install failed.") : output
659+
}
660+
}
661+
}

Planet/Planet.entitlements

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
<true/>
1111
<key>com.apple.security.files.bookmarks.app-scope</key>
1212
<true/>
13+
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
14+
<array>
15+
<string>/.local/</string>
16+
<string>/.zprofile</string>
17+
</array>
1318
<key>com.apple.security.network.client</key>
1419
<true/>
1520
<key>com.apple.security.network.server</key>

Planet/en.lproj/Localizable.strings

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010

1111
"Writer" = "";
1212
"Tools" = "";
13+
"Install CLI" = "";
14+
"CLI Already Installed" = "";
15+
"CLI Installed" = "";
16+
"Failed to Install CLI" = "";
17+
"Install the pn CLI to ~/.local/bin so it becomes available in Terminal?" = "";
18+
"A symbolic link for pn is already installed at ~/.local/bin/pn and can be used in Terminal." = "";
19+
"A symbolic link for pn has been installed at ~/.local/bin/pn and can be used in Terminal." = "";
20+
"The bundled pn helper could not be found." = "";
21+
"pn install failed." = "";
1322
"Template Browser" = "";
1423
"Downloads" = "";
1524
"Publish My Planets" = "";
@@ -270,6 +279,7 @@
270279
"Add Attachments" = "";
271280
"Or drag and drop images here." = "";
272281
"Cancel" = "";
282+
"Install" = "";
273283
"Post" = "";
274284
"Please scan the QR code with your wallet app" = "";
275285
"Copy URL" = "";
@@ -567,6 +577,7 @@
567577
"Open Local Gateway" = "Open Local Gateway";
568578
"Open Permalink in Browser" = "Open Permalink in Browser";
569579
"Open Shareable Link in Browser" = "Open Shareable Link in Browser";
580+
"Open Terminal" = "Open Terminal";
570581
"Open Template Folder in iTerm" = "Open Template Folder in iTerm";
571582
"Open in Terminal" = "Open in Terminal";
572583
"Open in Tower" = "Open in Tower";

Planet/versioning.xcconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
CURRENT_PROJECT_VERSION = 2823
1+
CURRENT_PROJECT_VERSION = 2824

PlanetCLI/PNCommandRunner.swift

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)