mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: support multiple sprout instances with stable ports (#264)
This commit is contained in:
@@ -33,14 +33,14 @@ export default defineConfig(async () => ({
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
port: parseInt(process.env.VITE_PORT || "1420", 10),
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
port: parseInt(process.env.VITE_HMR_PORT || "1421", 10),
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: sprout
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
|
||||
26
justfile
26
justfile
@@ -139,23 +139,35 @@ proxy:
|
||||
proxy-release:
|
||||
cargo run -p sprout-proxy --release
|
||||
|
||||
# Run the desktop Tauri app in dev mode (uses dev identifier for side-by-side with production)
|
||||
# Run the desktop Tauri app in dev mode (ports and identity derived from worktree)
|
||||
dev *ARGS:
|
||||
cd {{desktop_dir}} && pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}}
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd {{desktop_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
source ../scripts/instance-env.sh
|
||||
echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
|
||||
pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
|
||||
staging *ARGS:
|
||||
cd {{desktop_dir}} && pnpm install
|
||||
cargo build --release -p sprout-acp -p sprout-mcp
|
||||
cd {{desktop_dir}} && SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}}
|
||||
cd {{desktop_dir}} && SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" pnpm exec tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}}
|
||||
|
||||
# Run the desktop frontend dev server
|
||||
# Run the desktop frontend dev server (port derived from worktree)
|
||||
desktop-dev:
|
||||
cd {{desktop_dir}} && pnpm dev
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd {{desktop_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
source ../scripts/instance-env.sh
|
||||
echo "Starting frontend dev server on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
|
||||
pnpm exec vite --port "${SPROUT_VITE_PORT}" --strictPort
|
||||
|
||||
# Run the desktop Tauri app (uses dev identifier for side-by-side with production)
|
||||
# Run the desktop Tauri app (alias for dev)
|
||||
desktop-app *ARGS:
|
||||
cd {{desktop_dir}} && pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}}
|
||||
just dev {{ARGS}}
|
||||
|
||||
# ─── Desktop Release ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
217
scripts/generate-dev-icon.swift
Executable file
217
scripts/generate-dev-icon.swift
Executable file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// Generate a dev icon with worktree name badge
|
||||
// Usage: generate-dev-icon.swift <input-icns> <output-icns> <label>
|
||||
|
||||
guard CommandLine.arguments.count == 4 else {
|
||||
fputs("Usage: \(CommandLine.arguments[0]) <input-icns> <output-icns> <label>\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let inputPath = CommandLine.arguments[1]
|
||||
let outputPath = CommandLine.arguments[2]
|
||||
let label = CommandLine.arguments[3]
|
||||
|
||||
func sanitizedPathComponent(_ value: String) -> String {
|
||||
let lowered = value.lowercased()
|
||||
let sanitizedScalars = lowered.unicodeScalars.map { scalar -> Character in
|
||||
switch scalar {
|
||||
case "a"..."z", "0"..."9":
|
||||
return Character(scalar)
|
||||
default:
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
let collapsed = String(sanitizedScalars).replacingOccurrences(
|
||||
of: "-+",
|
||||
with: "-",
|
||||
options: .regularExpression
|
||||
)
|
||||
let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
return trimmed.isEmpty ? "dev" : trimmed
|
||||
}
|
||||
|
||||
// Load the icns file
|
||||
guard let iconImage = NSImage(contentsOfFile: inputPath) else {
|
||||
fputs("Failed to load image: \(inputPath)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Get the largest representation for best quality
|
||||
guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
|
||||
fputs("No image representations found\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)
|
||||
|
||||
// Create a new image with the badge
|
||||
let newImage = NSImage(size: size)
|
||||
newImage.lockFocus()
|
||||
|
||||
// Draw the original icon
|
||||
iconImage.draw(in: NSRect(origin: .zero, size: size))
|
||||
|
||||
// Configure badge
|
||||
let singleLineHeight = size.height * 0.22
|
||||
let padding = size.width * 0.03
|
||||
let maxBadgeWidth = size.width * 0.9
|
||||
|
||||
// Text attributes - calculate font size to fit
|
||||
let maxFontSize = singleLineHeight * 0.65
|
||||
var fontSize = maxFontSize
|
||||
var attributes: [NSAttributedString.Key: Any]
|
||||
|
||||
// Helper to wrap text on `-` characters
|
||||
func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
|
||||
let singleLineSize = (text as NSString).size(withAttributes: attributes)
|
||||
if singleLineSize.width <= maxWidth {
|
||||
return [text]
|
||||
}
|
||||
|
||||
// Split on `-` and try to form lines
|
||||
let parts = text.components(separatedBy: "-")
|
||||
if parts.count == 1 {
|
||||
return [text] // No `-` to wrap on
|
||||
}
|
||||
|
||||
var lines: [String] = []
|
||||
var currentLine = ""
|
||||
|
||||
for (index, part) in parts.enumerated() {
|
||||
let separator = index == 0 ? "" : "-"
|
||||
let testLine = currentLine.isEmpty ? part : currentLine + separator + part
|
||||
let testSize = (testLine as NSString).size(withAttributes: attributes)
|
||||
|
||||
if testSize.width <= maxWidth || currentLine.isEmpty {
|
||||
currentLine = testLine
|
||||
} else {
|
||||
lines.append(currentLine)
|
||||
currentLine = part
|
||||
}
|
||||
}
|
||||
if !currentLine.isEmpty {
|
||||
lines.append(currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// Find font size that fits (allowing up to 2 lines)
|
||||
var lines: [String] = []
|
||||
repeat {
|
||||
attributes = [
|
||||
.font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
|
||||
.foregroundColor: NSColor.white
|
||||
]
|
||||
lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
|
||||
fontSize -= 1
|
||||
} while lines.count > 2 && fontSize > 8
|
||||
|
||||
// Calculate text dimensions using typographic metrics
|
||||
let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
|
||||
let textHeight = lineHeight * CGFloat(lines.count)
|
||||
let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0
|
||||
|
||||
// Badge dimensions based on text
|
||||
let badgeHeight = textHeight + padding * 2
|
||||
let cornerRadius = badgeHeight * 0.2
|
||||
let badgeWidth = maxLineWidth + padding * 4
|
||||
let badgeX = (size.width - badgeWidth) / 2
|
||||
let badgeY = size.height - badgeHeight - padding - size.height * 0.05
|
||||
|
||||
// Draw badge background (light blue semi-transparent)
|
||||
let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
|
||||
xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
|
||||
badgePath.fill()
|
||||
|
||||
// Draw text centered in badge (multiple lines, bottom to top)
|
||||
for (index, line) in lines.reversed().enumerated() {
|
||||
let lineSize = (line as NSString).size(withAttributes: attributes)
|
||||
let textX = badgeX + (badgeWidth - lineSize.width) / 2
|
||||
let textY = badgeY + padding + lineHeight * CGFloat(index)
|
||||
(line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
|
||||
}
|
||||
|
||||
newImage.unlockFocus()
|
||||
|
||||
// Convert to icns format
|
||||
// First, create PNG data at multiple sizes for icns
|
||||
guard let tiffData = newImage.tiffRepresentation,
|
||||
let bitmapRep = NSBitmapImageRep(data: tiffData) else {
|
||||
fputs("Failed to create bitmap representation\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// For simplicity, we'll create a PNG and then use iconutil if available,
|
||||
// or just save as PNG for the icon (Tauri can use PNG)
|
||||
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
|
||||
fputs("Failed to create PNG data\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// If output is .icns, we need to create an iconset and use iconutil
|
||||
if outputPath.hasSuffix(".icns") {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let iconsetParent = tempDir.appendingPathComponent(
|
||||
"staged-dev-\(sanitizedPathComponent(label))-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
let iconsetPath = iconsetParent.appendingPathComponent("icon.iconset", isDirectory: true)
|
||||
try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)
|
||||
|
||||
// Generate all required sizes for iconset
|
||||
let sizes: [(name: String, size: Int)] = [
|
||||
("icon_16x16", 16),
|
||||
("icon_16x16@2x", 32),
|
||||
("icon_32x32", 32),
|
||||
("icon_32x32@2x", 64),
|
||||
("icon_128x128", 128),
|
||||
("icon_128x128@2x", 256),
|
||||
("icon_256x256", 256),
|
||||
("icon_256x256@2x", 512),
|
||||
("icon_512x512", 512),
|
||||
("icon_512x512@2x", 1024)
|
||||
]
|
||||
|
||||
for (name, targetSize) in sizes {
|
||||
let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
|
||||
resizedImage.lockFocus()
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
|
||||
resizedImage.unlockFocus()
|
||||
|
||||
guard let resizedTiff = resizedImage.tiffRepresentation,
|
||||
let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
|
||||
let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = iconsetPath.appendingPathComponent("\(name).png")
|
||||
try! resizedPng.write(to: filePath)
|
||||
}
|
||||
|
||||
// Use iconutil to create icns
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
|
||||
process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
|
||||
try! process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
// Cleanup
|
||||
try? FileManager.default.removeItem(at: iconsetParent)
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
fputs("iconutil failed\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
} else {
|
||||
// Just save as PNG
|
||||
try! pngData.write(to: URL(fileURLWithPath: outputPath))
|
||||
}
|
||||
|
||||
print("Generated: \(outputPath)")
|
||||
46
scripts/instance-env.sh
Executable file
46
scripts/instance-env.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Computes the full multi-instance desktop dev environment.
|
||||
# Source this file from desktop dev commands; it exports:
|
||||
# SPROUT_VITE_PORT, SPROUT_HMR_PORT, VITE_PORT, VITE_HMR_PORT
|
||||
# SPROUT_RELAY_PORT, SPROUT_RELAY_URL
|
||||
# SPROUT_INSTANCE_SLUG, SPROUT_WORKTREE_LABEL, VITE_DEV_BRANCH (worktrees only)
|
||||
# SPROUT_TAURI_CONFIG
|
||||
|
||||
WORKTREE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||
|
||||
# Derive a stable base port from the worktree root so the same worktree always
|
||||
# gets the same ports. This keeps the Tauri dev config stable between runs and
|
||||
# preserves Cargo's build cache.
|
||||
BASE_PORT=$(python3 -c "import hashlib,sys; h=int(hashlib.sha256(sys.argv[1].encode()).hexdigest(), 16); print(10000 + h % 55000)" "$WORKTREE_ROOT")
|
||||
export SPROUT_VITE_PORT=$BASE_PORT
|
||||
export SPROUT_HMR_PORT=$((BASE_PORT + 1))
|
||||
export SPROUT_RELAY_PORT=3000
|
||||
export VITE_PORT="$SPROUT_VITE_PORT"
|
||||
export VITE_HMR_PORT="$SPROUT_HMR_PORT"
|
||||
export SPROUT_RELAY_URL="${SPROUT_RELAY_URL:-ws://localhost:3000}"
|
||||
|
||||
SPROUT_TAURI_CONFIG="{\"build\":{\"devUrl\":\"http://localhost:${SPROUT_VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${SPROUT_VITE_PORT} --strictPort\"},\"identifier\":\"xyz.block.sprout.app.dev\",\"productName\":\"Sprout Dev\"}"
|
||||
unset VITE_DEV_BRANCH
|
||||
|
||||
# In worktrees, extract a label from the branch name and derive a unique app
|
||||
# identity and icon so multiple local desktop instances can run side by side.
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
|
||||
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
|
||||
export SPROUT_WORKTREE_LABEL="${BRANCH_NAME##*/}"
|
||||
export SPROUT_INSTANCE_SLUG=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
|
||||
|
||||
ICON_DIR="$(pwd)/src-tauri/target/dev-icons"
|
||||
mkdir -p "$ICON_DIR"
|
||||
DEV_ICON="$ICON_DIR/icon.icns"
|
||||
|
||||
if swift ../scripts/generate-dev-icon.swift src-tauri/icons/icon.icns "$DEV_ICON" "$SPROUT_WORKTREE_LABEL"; then
|
||||
echo "🌳 Worktree: ${SPROUT_WORKTREE_LABEL}"
|
||||
export VITE_DEV_BRANCH="$SPROUT_WORKTREE_LABEL"
|
||||
SPROUT_TAURI_CONFIG="{\"build\":{\"devUrl\":\"http://localhost:${SPROUT_VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${SPROUT_VITE_PORT} --strictPort\"},\"identifier\":\"xyz.block.sprout.app.dev.${SPROUT_INSTANCE_SLUG}\",\"productName\":\"Sprout Dev (${SPROUT_WORKTREE_LABEL})\",\"bundle\":{\"icon\":[\"$DEV_ICON\"]}}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
export SPROUT_TAURI_CONFIG
|
||||
Reference in New Issue
Block a user