Skip to content

Commit 1dccf4a

Browse files
committed
Harden lifecycle and refresh onboarding
1 parent 3cf3076 commit 1dccf4a

23 files changed

Lines changed: 1368 additions & 887 deletions

.github/workflows/cli.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: CLI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
cli-tests:
11+
runs-on: macos-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Ensure jq is installed
17+
run: |
18+
command -v jq >/dev/null 2>&1 || brew install jq
19+
20+
- name: Syntax check CLI and tests
21+
run: |
22+
bash -n bin/devkit
23+
bash -n test/test_registry.sh
24+
bash -n devkit_test/test_registry.sh
25+
26+
- name: Run CLI integration suite
27+
run: bash test/test_registry.sh

.github/workflows/installer.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Installer
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
installer-smoke:
11+
runs-on: macos-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Ensure jq is installed
17+
run: |
18+
command -v jq >/dev/null 2>&1 || brew install jq
19+
20+
- name: Syntax check installer smoke test
21+
run: bash -n test/test_install.sh
22+
23+
- name: Run installer smoke test
24+
run: bash test/test_install.sh

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Core primitives:
1212

1313
- a registry of local apps (`apps.json`, gitignored)
1414
- stable localhost hostnames via Caddy
15-
- `pm2` lifecycle helpers
15+
- native PID-file lifecycle helpers
1616
- a generated dashboard at `http://dash.localhost`
1717
- `devkit edit <name>` → drops into project with Claude Code running
1818

MenuBarApp/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ All notable changes to devkit are documented here.
2626

2727
**Known issues (beta)**
2828

29-
- Stop may not kill externally-supervised processes (pm2, nodemon, launchd-managed)
29+
- Externally-supervised processes (nodemon, launchd, custom respawners) can come back after stop
3030
- Auto-update not yet implemented
3131
- Launch at login not yet implemented
3232
- Homebrew cask not yet published

MenuBarApp/DevkitBar/DevkitBar.entitlements

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5-
<!-- Sandbox off: we need to spawn devkit/pm2 processes and read arbitrary paths -->
5+
<!-- Sandbox off: we need to spawn devkit-managed processes and read arbitrary paths -->
66
<key>com.apple.security.app-sandbox</key>
77
<false/>
88
<key>com.apple.security.network.client</key>

MenuBarApp/DevkitBar/Services/AppRegistry.swift

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,17 @@ final class AppRegistry: ObservableObject {
141141
guard !pendingOps.contains(app.id) else { return }
142142
pendingOps.insert(app.id)
143143
statuses[app.id] = .checking
144+
errorMessage = nil
144145

145146
Task {
146147
defer { pendingOps.remove(app.id) }
147148

148-
await DevkitCLI.start(app.name)
149+
let result = await DevkitCLI.start(app.name)
150+
guard result.exitCode == 0 else {
151+
statuses[app.id] = .stopped
152+
errorMessage = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
153+
return
154+
}
149155

150156
// Poll until the port responds (up to 6s)
151157
for _ in 0..<10 {
@@ -163,57 +169,34 @@ final class AppRegistry: ObservableObject {
163169
guard !pendingOps.contains(app.id) else { return }
164170
pendingOps.insert(app.id)
165171
statuses[app.id] = .checking
172+
errorMessage = nil
166173

167174
Task {
168175
defer { pendingOps.remove(app.id) }
169176

170-
// 1. Tell devkit to stop it (updates its own process table)
171-
await DevkitCLI.stop(app.name)
172-
173-
// 2. Kill by port — targets the LISTENING process only (-sTCP:LISTEN),
174-
// works regardless of what devkit's stop command does.
175-
// SIGTERM first, then SIGKILL 500ms later if still alive.
176-
await killListeningProcess(on: app.port)
177+
let result = await DevkitCLI.stop(app.name)
178+
guard result.exitCode == 0 else {
179+
let stillUp = await PortChecker.isReachable(port: app.port)
180+
statuses[app.id] = stillUp ? .running : .stopped
181+
errorMessage = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
182+
return
183+
}
177184

178-
// 3. Verify: poll until port is actually closed (up to 4s)
179-
for _ in 0..<8 {
185+
// Verify: poll until port is actually closed (up to 6s)
186+
for _ in 0..<12 {
180187
try? await Task.sleep(for: .milliseconds(500))
181188
if !(await PortChecker.isReachable(port: app.port)) {
182189
statuses[app.id] = .stopped
183190
return
184191
}
185192
}
186193

187-
// Port is still open after 4s — let polling decide the real status
188-
// (process might be managed externally and respawned)
194+
// Port is still open after 6s — let polling decide the real status.
189195
let stillUp = await PortChecker.isReachable(port: app.port)
190196
statuses[app.id] = stillUp ? .running : .stopped
191197
}
192198
}
193199

194-
// MARK: – Port kill
195-
196-
/// Kills the process listening on the given TCP port.
197-
/// Uses `-sTCP:LISTEN` so only the server process is targeted, not clients connected to it.
198-
private func killListeningProcess(on port: Int) async {
199-
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
200-
let task = Process()
201-
task.executableURL = URL(fileURLWithPath: "/bin/sh")
202-
task.arguments = ["-c", """
203-
pids=$(lsof -nP -iTCP:\(port) -sTCP:LISTEN -t 2>/dev/null)
204-
if [ -n "$pids" ]; then
205-
echo "$pids" | xargs kill -TERM 2>/dev/null
206-
sleep 0.5
207-
pids=$(lsof -nP -iTCP:\(port) -sTCP:LISTEN -t 2>/dev/null)
208-
[ -n "$pids" ] && echo "$pids" | xargs kill -KILL 2>/dev/null
209-
fi
210-
"""]
211-
task.terminationHandler = { _ in cont.resume() }
212-
do { try task.run() }
213-
catch { cont.resume() }
214-
}
215-
}
216-
217200
// MARK: – Derived
218201

219202
func status(for app: AppEntry) -> AppStatus { statuses[app.id] ?? .checking }

MenuBarApp/DevkitBar/Services/DevkitCLI.swift

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import Foundation
22

33
enum DevkitCLI {
4+
struct Result {
5+
let output: String
6+
let exitCode: Int32
7+
}
8+
49
// Resolved once at startup. Checks known locations first, then falls back
510
// to asking the shell (sources ~/.zshrc so custom PATH installs are found).
611
static let binaryPath: String? = {
@@ -29,12 +34,14 @@ enum DevkitCLI {
2934
}()
3035

3136
@discardableResult
32-
static func run(_ subcommand: String) async -> String {
33-
guard let binary = binaryPath else { return "" }
37+
static func run(_ args: [String]) async -> Result {
38+
guard let binary = binaryPath else {
39+
return Result(output: "devkit not found — install the CLI first", exitCode: 127)
40+
}
3441
return await withCheckedContinuation { cont in
3542
let task = Process()
3643
task.executableURL = URL(fileURLWithPath: "/bin/zsh")
37-
task.arguments = ["-l", "-c", "source ~/.zshrc 2>/dev/null; \(binary) \(subcommand)"]
44+
task.arguments = ["-l", "-c", "source ~/.zshrc 2>/dev/null; exec \"$@\"", "devkit", binary] + args
3845

3946
var env = ProcessInfo.processInfo.environment
4047
env["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
@@ -44,22 +51,25 @@ enum DevkitCLI {
4451
task.standardOutput = out
4552
task.standardError = err
4653

47-
task.terminationHandler = { _ in
48-
let data = out.fileHandleForReading.readDataToEndOfFile()
49-
cont.resume(returning: String(data: data, encoding: .utf8) ?? "")
54+
task.terminationHandler = { task in
55+
let data = out.fileHandleForReading.readDataToEndOfFile() + err.fileHandleForReading.readDataToEndOfFile()
56+
cont.resume(returning: Result(
57+
output: String(data: data, encoding: .utf8) ?? "",
58+
exitCode: task.terminationStatus
59+
))
5060
}
5161
do { try task.run() }
52-
catch { cont.resume(returning: "") }
62+
catch { cont.resume(returning: Result(output: "", exitCode: 1)) }
5363
}
5464
}
5565

5666
static func appsJSONPath() async -> String? {
57-
let output = await run("paths")
58-
return output.split(separator: "\n")
67+
let result = await run(["paths"])
68+
return result.output.split(separator: "\n")
5969
.first { $0.hasPrefix("APPS_JSON=") }
6070
.map { String($0.dropFirst("APPS_JSON=".count)) }
6171
}
6272

63-
static func start(_ name: String) async { await run("start \(name)") }
64-
static func stop(_ name: String) async { await run("stop \(name)") }
73+
static func start(_ name: String) async -> Result { await run(["start", name]) }
74+
static func stop(_ name: String) async -> Result { await run(["stop", name]) }
6575
}

MenuBarApp/DevkitBar/Views/AppRowView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ private struct StatusDot: View {
147147
.onAppear {
148148
if status == .running { pulse = true }
149149
}
150-
.onChange(of: status) { new in
151-
pulse = (new == .running)
150+
.onChange(of: status) { _, newValue in
151+
pulse = (newValue == .running)
152152
}
153153
.animation(.easeInOut(duration: 0.3), value: status)
154154
}

MenuBarApp/DevkitBar/Views/MenuBarView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ struct MenuBarView: View {
8282
.textFieldStyle(.plain)
8383
.font(.system(size: 12.5))
8484
.focused($searchFocused)
85-
.onChange(of: searchText) { v in searchPublisher.send(v) }
85+
.onChange(of: searchText) { _, newValue in searchPublisher.send(newValue) }
8686
.onExitCommand { searchText = ""; debouncedQuery = ""; searchFocused = false }
8787

8888
if !searchText.isEmpty {

0 commit comments

Comments
 (0)