diff --git a/.gitignore b/.gitignore index 7195b6762..aba962644 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ node_modules **/.claude/settings.local.json .claude/worktrees/ +# Marker file written by scripts/task-setup.sh at each worktree root. Untracked +# inside the worktree's own checkout, so it must be ignored to keep `git status` +# clean (otherwise `make task-clean` would always refuse without FORCE=1). +.task + # Environment files .env .env.* diff --git a/Makefile b/Makefile index e0f583359..1adf9ad0b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Development Makefile for Lobu -.PHONY: help setup build test eval clean dev build-packages ensure-submodule clean-workers test-unit test-integration test-e2e typecheck +.PHONY: help setup build test eval clean dev build-packages ensure-submodule clean-workers test-unit test-integration test-e2e typecheck task-setup task-clean task-use # Default target help: @@ -15,6 +15,9 @@ help: @echo " make eval - Run agent evals" @echo " make clean-workers - Stop any running embedded worker subprocesses" @echo " make typecheck - Strict typecheck (same as Dockerfile) for server + owletto" + @echo " make task-setup NAME= - Create a paired worktree at .claude/worktrees/ (lobu + submodule on real branch, .env copied, ports auto-assigned, Lobu context registered)" + @echo " make task-clean NAME= [FORCE=1] - Remove the worktree, both branches, and the Lobu context (refuses if there's uncommitted/unpushed work unless FORCE=1)" + @echo " make task-use NAME= - Point Chrome ext / Mac app symlinks at this worktree (or 'main' for the canonical checkout)" # Strict typecheck — mirrors the Dockerfile so local matches CI. Catches # what `build-packages` (relaxed, bundler-only) misses. @@ -70,6 +73,23 @@ test: eval: @npx @lobu/cli@latest eval +# --- Task worktrees --------------------------------------------------------- +# Paired-branch worktrees for parallel work without losing changes to the +# packages/owletto submodule. See scripts/task-setup.sh header for details +# (the script also documents an optional `task-start` shell function alias). + +task-setup: + @: $${NAME?Usage: make task-setup NAME=} + @./scripts/task-setup.sh "$(NAME)" + +task-clean: + @: $${NAME?Usage: make task-clean NAME= [FORCE=1]} + @./scripts/task-clean.sh "$(NAME)" $$( [ "$(FORCE)" = "1" ] && echo --force ) + +task-use: + @: $${NAME?Usage: make task-use NAME=} + @./scripts/task-use.sh "$(NAME)" + # --- Test pipelines --------------------------------------------------------- # These mirror what CI runs (.github/workflows/ci.yml) so a passing local run # is a strong signal CI will pass. diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 63cec1a16..a650cebe5 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -3,9 +3,11 @@ import { addContext, getCurrentContextName, loadContextConfig, + removeContext, resolveContext, setCurrentContext, } from "../internal/index.js"; +import type { LobuServerConfig } from "../internal/context.js"; export async function contextListCommand(): Promise { const config = await loadContextConfig(); @@ -45,13 +47,36 @@ export async function contextCurrentCommand(): Promise { export async function contextAddCommand(options: { name: string; apiUrl: string; + port?: number; + host?: string; + databaseUrl?: string; + dataDir?: string; + cwd?: string; + lifecycle?: "managed" | "external"; }): Promise { - await addContext(options.name, options.apiUrl); + const server: LobuServerConfig = {}; + if (options.port !== undefined) server.port = options.port; + if (options.host) server.host = options.host; + if (options.databaseUrl) server.databaseUrl = options.databaseUrl; + if (options.dataDir) server.dataDir = options.dataDir; + if (options.cwd) server.cwd = options.cwd; + if (options.lifecycle) server.lifecycle = options.lifecycle; + + await addContext( + options.name, + options.apiUrl, + Object.keys(server).length === 0 ? undefined : server + ); console.log( chalk.green(`\n Saved context ${options.name} -> ${options.apiUrl}\n`) ); } +export async function contextRmCommand(name: string): Promise { + await removeContext(name); + console.log(chalk.dim(`\n Removed context ${name}\n`)); +} + export async function contextUseCommand(name: string): Promise { const trimmedName = name.trim(); const config = await setCurrentContext(trimmedName); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0a9556385..18e53567a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -543,10 +543,69 @@ Memory: .command("add ") .description("Add a named context") .requiredOption("--api-url ", "API base URL for this context") - .action(async (name: string, options: { apiUrl: string }) => { - const { contextAddCommand } = await import("./commands/context.js"); - await contextAddCommand({ name, apiUrl: options.apiUrl }); - }); + .option( + "--port ", + "Server port (when this context owns a managed lobu server)", + (value: string) => { + if (!/^\d+$/.test(value)) { + throw new Error(`--port must be an integer, got "${value}"`); + } + const n = Number.parseInt(value, 10); + if (n < 1 || n > 65535) { + throw new Error(`--port must be in 1-65535, got ${n}`); + } + return n; + } + ) + .option("--host ", "Server host (default: 127.0.0.1)") + .option( + "--database-url ", + "Postgres DATABASE_URL for the managed server" + ) + .option( + "--data-dir ", + "LOBU_DATA_DIR for the managed server (state, PGlite)" + ) + .option( + "--cwd ", + "Working directory the lifecycle owner cd's into before spawning `lobu run` (used by per-worktree contexts)" + ) + .option( + "--lifecycle ", + "managed | external — managed means the menubar spawns `lobu run`", + (value: string) => { + if (value !== "managed" && value !== "external") { + throw new Error(`--lifecycle must be 'managed' or 'external'`); + } + return value; + } + ) + .action( + async ( + name: string, + options: { + apiUrl: string; + port?: number; + host?: string; + databaseUrl?: string; + dataDir?: string; + cwd?: string; + lifecycle?: "managed" | "external"; + } + ) => { + const { contextAddCommand } = await import("./commands/context.js"); + await contextAddCommand({ + name, + apiUrl: options.apiUrl, + port: options.port, + host: options.host, + databaseUrl: options.databaseUrl, + dataDir: options.dataDir, + cwd: options.cwd, + lifecycle: options.lifecycle, + }); + } + ); context .command("use ") @@ -556,6 +615,14 @@ Memory: await contextUseCommand(name); }); + context + .command("rm ") + .description("Remove a named context (idempotent)") + .action(async (name: string) => { + const { contextRmCommand } = await import("./commands/context.js"); + await contextRmCommand(name); + }); + // ─── status ───────────────────────────────────────────────────────── withCommonOpts( program diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index 69693d99b..ca9385012 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -9,12 +9,14 @@ import { } from "bun:test"; import * as fs from "node:fs/promises"; import { + addContext, DEFAULT_CONTEXT_NAME, findContextByMemoryUrl, findContextByUrl, getActiveOrg, getServerConfig, loadContextConfig, + removeContext, setActiveOrg, setServerConfig, } from "../context"; @@ -143,6 +145,93 @@ describe("context management", () => { }); }); + test("addContext stores optional server config (port + cwd + lifecycle)", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await addContext("verify-flow", "http://localhost:8788", { + port: 8788, + cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", + lifecycle: "managed", + }); + + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts["verify-flow"]).toEqual({ + apiUrl: "http://localhost:8788", + server: { + port: 8788, + cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", + lifecycle: "managed", + }, + }); + }); + + test("addContext refuses to overwrite the default context", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + contexts: { + [DEFAULT_CONTEXT_NAME]: { apiUrl: "https://app.lobu.ai/api/v1" }, + }, + }) + ); + + await expect( + addContext(DEFAULT_CONTEXT_NAME, "http://localhost:8788") + ).rejects.toThrow(/Cannot overwrite the default context/); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + + test("addContext without server keeps shape backwards-compatible", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await addContext("plain", "https://example.com/api/v1"); + + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts.plain).toEqual({ + apiUrl: "https://example.com/api/v1", + }); + }); + + test("removeContext deletes the entry and resets currentContext if needed", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + currentContext: "verify-flow", + contexts: { + lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, + "verify-flow": { apiUrl: "http://localhost:8788" }, + }, + }) + ); + + await removeContext("verify-flow"); + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts["verify-flow"]).toBeUndefined(); + expect(saved.currentContext).toBe(DEFAULT_CONTEXT_NAME); + }); + + test("removeContext is idempotent for missing entries", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await removeContext("never-existed"); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + + test("removeContext refuses the default context", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + contexts: { + [DEFAULT_CONTEXT_NAME]: { apiUrl: "https://app.lobu.ai/api/v1" }, + }, + }) + ); + + await expect(removeContext(DEFAULT_CONTEXT_NAME)).rejects.toThrow( + /Cannot remove the default context/ + ); + }); + test("drops invalid server fields during normalization", async () => { const configData = { currentContext: "local", diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index 996e1e6b7..b41459051 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -15,6 +15,11 @@ export interface LobuServerConfig { port?: number; host?: string; dataDir?: string; + // Directory the lifecycle owner should `cd` into before spawning + // `lobu run`. Used by per-worktree contexts so the menubar launches + // the server against the worktree's source (hot-reload on the right + // checkout). Absent → spawner uses its own cwd. + cwd?: string; // "managed" → the Mac menubar (or another lifecycle owner) spawns // `lobu run` for this context. "external" → just connect; never // spawn or kill. Absent → infer from apiUrl: loopback ⇒ managed, @@ -174,17 +179,51 @@ export async function resolveContext( export async function addContext( name: string, - apiUrl: string + apiUrl: string, + server?: LobuServerConfig ): Promise { const trimmedName = name.trim(); if (!trimmedName) { throw new Error("Context name cannot be empty."); } + if (trimmedName === DEFAULT_CONTEXT_NAME) { + throw new Error( + `Cannot overwrite the default context "${trimmedName}". Pick a different name.` + ); + } const config = await loadContextConfig(); - config.contexts[trimmedName] = { + const entry: LobuContextEntry = { apiUrl: normalizeAndValidateApiUrl(apiUrl), }; + const normalizedServer = server ? normalizeServerConfig(server) : undefined; + if (normalizedServer) { + entry.server = normalizedServer; + } + config.contexts[trimmedName] = entry; + await saveContextConfig(config); + return config; +} + +export async function removeContext(name: string): Promise { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new Error("Context name cannot be empty."); + } + + const config = await loadContextConfig(); + if (!config.contexts[trimmedName]) { + // Idempotent: removing a non-existent context is a no-op. + return config; + } + if (trimmedName === DEFAULT_CONTEXT_NAME) { + throw new Error(`Cannot remove the default context "${trimmedName}".`); + } + + delete config.contexts[trimmedName]; + if (config.currentContext === trimmedName) { + config.currentContext = DEFAULT_CONTEXT_NAME; + } await saveContextConfig(config); return config; } @@ -264,6 +303,9 @@ function normalizeServerConfig(raw: unknown): LobuServerConfig | undefined { if (typeof src.dataDir === "string" && src.dataDir.trim()) { out.dataDir = src.dataDir.trim(); } + if (typeof src.cwd === "string" && src.cwd.trim()) { + out.cwd = src.cwd.trim(); + } if (src.lifecycle === "managed" || src.lifecycle === "external") { out.lifecycle = src.lifecycle; } diff --git a/packages/cli/src/internal/index.ts b/packages/cli/src/internal/index.ts index d40964224..b09d8b0c0 100644 --- a/packages/cli/src/internal/index.ts +++ b/packages/cli/src/internal/index.ts @@ -5,6 +5,7 @@ export { getCurrentContextName, getMemoryUrl, loadContextConfig, + removeContext, resolveContext, setActiveOrg, setCurrentContext, diff --git a/scripts/task-clean.sh b/scripts/task-clean.sh new file mode 100755 index 000000000..d756998e0 --- /dev/null +++ b/scripts/task-clean.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# task-clean.sh — remove a task worktree, its branches in both repos, +# and its Lobu CLI context entry. +# +# Usage: +# scripts/task-clean.sh [--force] +# +# Refuses by default when there is unfinished work: +# - uncommitted changes in the lobu worktree or in packages/owletto +# - commits on feat/ not pushed to origin +# +# Pass --force (or `make task-clean NAME= FORCE=1`) to override. + +set -euo pipefail + +usage() { + echo "usage: $0 [--force]" >&2 + exit 1 +} + +force=0 +name="" +for arg in "$@"; do + case "$arg" in + --force|-f) force=1 ;; + -*) echo "error: unknown flag '$arg'" >&2; usage ;; + *) [[ -z "$name" ]] && name="$arg" || usage ;; + esac +done +[[ -n "$name" ]] || usage + +if ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "error: name must be kebab-case: '$name'" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +worktree_dir="$repo/.claude/worktrees/$name" +branch="feat/$name" + +if [[ ! -d "$worktree_dir" ]]; then + echo "error: no worktree at $worktree_dir" >&2 + exit 1 +fi + +# Count commits on $branch that aren't reachable from $upstream. Echoes 0 when +# $upstream is missing (treats "no upstream" as "no proven publish"; caller +# may still bail elsewhere). Echoes a positive number if the branch is ahead. +ahead_of() { + local gitdir="$1" upstream="$2" + if ! git -C "$gitdir" rev-parse --verify "$upstream" >/dev/null 2>&1; then + echo 0 + return + fi + git -C "$gitdir" rev-list --count "$upstream..$branch" 2>/dev/null || echo 0 +} + +if [[ $force -eq 0 ]]; then + # Refresh remote-tracking refs so the "ahead of origin/" check below + # doesn't trust a stale local ref (e.g. branch deleted on remote → we'd see + # origin/ at its old position and conclude 0 unpushed commits). + (cd "$worktree_dir" && git fetch origin --prune --quiet) || true + if [[ -d "$worktree_dir/packages/owletto" ]]; then + (cd "$worktree_dir/packages/owletto" && git fetch origin --prune --quiet) || true + fi + + if [[ -n "$(git -C "$worktree_dir" status --porcelain)" ]]; then + echo "error: uncommitted changes in $worktree_dir (pass --force to discard)" >&2 + exit 1 + fi + if [[ -d "$worktree_dir/packages/owletto" ]] \ + && [[ -n "$(git -C "$worktree_dir/packages/owletto" status --porcelain)" ]]; then + echo "error: uncommitted changes in packages/owletto (pass --force to discard)" >&2 + exit 1 + fi + + ahead_lobu="$(ahead_of "$worktree_dir" "origin/$branch")" + # When the branch was never pushed, fall back to "ahead of origin/main". + if ! git -C "$worktree_dir" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + ahead_lobu="$(ahead_of "$worktree_dir" "origin/main")" + fi + if [[ "$ahead_lobu" != "0" ]]; then + echo "error: lobu $branch has $ahead_lobu unpushed commit(s) (pass --force to discard)" >&2 + exit 1 + fi + + if [[ -d "$worktree_dir/packages/owletto" ]]; then + ahead_owl="$(ahead_of "$worktree_dir/packages/owletto" "origin/$branch")" + if ! git -C "$worktree_dir/packages/owletto" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + ahead_owl="$(ahead_of "$worktree_dir/packages/owletto" "origin/main")" + fi + if [[ "$ahead_owl" != "0" ]]; then + echo "error: owletto $branch has $ahead_owl unpushed commit(s) (pass --force to discard)" >&2 + exit 1 + fi + fi +fi + +echo "→ removing worktree $worktree_dir" +git -C "$repo" worktree remove "$worktree_dir" --force + +if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then + echo "→ deleting lobu branch $branch" + git -C "$repo" branch -D "$branch" +fi + +if [[ -d "$repo/packages/owletto" ]] \ + && git -C "$repo/packages/owletto" show-ref --verify --quiet "refs/heads/$branch"; then + echo "→ deleting owletto branch $branch" + git -C "$repo/packages/owletto" branch -D "$branch" +fi + +if command -v lobu >/dev/null 2>&1; then + if lobu context rm "$name" >/dev/null 2>&1; then + echo "→ removed Lobu context '$name'" + fi +fi + +# If this worktree was the active target for the Chrome/Mac symlinks, fall +# back to 'main' so the symlinks don't dangle into a deleted directory. +active_name_file="$HOME/.config/lobu-dev/active/.active-name" +if [[ -f "$active_name_file" ]] && [[ "$(cat "$active_name_file")" == "$name" ]]; then + "$repo/scripts/task-use.sh" main >/dev/null && \ + echo "→ active worktree was '$name'; reset to 'main'" +fi + +echo "✓ cleaned up task '$name'" diff --git a/scripts/task-setup.sh b/scripts/task-setup.sh new file mode 100755 index 000000000..0e864eb66 --- /dev/null +++ b/scripts/task-setup.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# task-setup.sh — prepare a paired-branch worktree for a new task. +# +# Usage: +# make task-setup NAME= (recommended, team-friendly) +# scripts/task-setup.sh (direct invocation) +# +# kebab-case task slug (e.g. fix-sse-leak) +# +# Companion: `make task-clean NAME= [FORCE=1]` removes the worktree, +# its branches in both repos, and the Lobu CLI context. +# +# Behavior (idempotent — re-running on an existing worktree refreshes .env +# and .env.local only): +# 1. Creates a lobu worktree at .claude/worktrees/ on branch feat/. +# 2. Initializes packages/owletto submodule on a real named branch feat/ +# (never detached HEAD — fixes the "we keep losing changes" bug). +# 3. Runs `bun install` after submodule init (avoids the bun.lock prune bug). +# 4. Copies .env from the main repo (gitignored secrets don't auto-carry into +# a fresh worktree, so `make dev` / `lobu run` would otherwise fail at boot). +# 5. Writes .env.local with PORT / WORKER_PROXY_PORT picked to avoid collisions +# with other worktrees and the main repo (which defaults to 8787 / 8118). +# 6. Drops a .task marker file at the worktree root so `git worktree list` +# can distinguish human task-worktrees from agent-* isolation worktrees. +# +# Optional shell-function sugar — `make task-setup` does the setup, then you +# still have to `cd && claude` by hand. If you want one command that +# also moves your shell and launches claude, add this to ~/.zshrc: +# +# task-start() { +# local name="$1"; local repo="$HOME/Code/lobu" +# "$repo/scripts/task-setup.sh" "$name" || return $? +# cd "$repo/.claude/worktrees/$name" && exec claude +# } +# task-resume() { +# local name="$1"; local repo="$HOME/Code/lobu" +# [[ -d "$repo/.claude/worktrees/$name" ]] \ +# || { echo "no such worktree: $name"; return 1; } +# cd "$repo/.claude/worktrees/$name" && exec claude +# } +# +# The cd + exec must live in the shell function (not a Makefile target or this +# script) so that the parent terminal actually moves and Warp/iTerm detect the +# new working directory. + +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 + echo " kebab-case task slug (lowercase letters/digits, hyphens)" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage +name="$1" + +if ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "error: name must be kebab-case: '$name'" >&2 + exit 1 +fi + +# Reserve names that match the built-in Lobu CLI contexts so `task-setup` never +# clobbers a global context (it calls `lobu context add `, which would +# overwrite the entry — and `lobu context rm` refuses the default, so cleanup +# wouldn't recover). Keep this list in sync with the contexts most users have. +case "$name" in + lobu|dev|local) + echo "error: '$name' is a reserved CLI context name; pick a feature slug" >&2 + exit 1 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +worktree_dir="$repo/.claude/worktrees/$name" +branch="feat/$name" + +refresh_only=0 +if [[ -d "$worktree_dir" ]]; then + echo "→ worktree exists; refreshing .env + .env.local only" + refresh_only=1 +fi + +if [[ $refresh_only -eq 0 ]]; then + echo "→ creating lobu worktree: $worktree_dir on $branch" + (cd "$repo" && git fetch origin --quiet) + if (cd "$repo" && git show-ref --verify --quiet "refs/heads/$branch"); then + (cd "$repo" && git worktree add "$worktree_dir" "$branch") + else + (cd "$repo" && git worktree add "$worktree_dir" -b "$branch" origin/main) + fi + + echo "→ preparing packages/owletto submodule on $branch (real branch, not detached)" + (cd "$worktree_dir" && git submodule update --init packages/owletto) + # Branch from the submodule HEAD (the SHA the parent pins), NOT origin/main — + # the pin and origin/main can differ, and using origin/main here would + # silently bump the submodule pointer in the new worktree. + ( + cd "$worktree_dir/packages/owletto" + git fetch origin --quiet + if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + git switch -c "$branch" --track "origin/$branch" + elif git show-ref --verify --quiet "refs/heads/$branch"; then + git switch "$branch" + else + git switch -c "$branch" HEAD + fi + ) + + echo "→ bun install" + (cd "$worktree_dir" && bun install) +fi + +if [[ -f "$repo/.env" ]]; then + cp "$repo/.env" "$worktree_dir/.env" + echo "→ copied .env from main repo" +else + echo "warning: no .env in $repo — worktree will lack secrets" >&2 +fi + +highest_port=8787 +highest_proxy=8118 +shopt -s nullglob +for env_local in "$repo"/.claude/worktrees/*/.env.local; do + [[ "$env_local" == "$worktree_dir/.env.local" ]] && continue + p="$(awk -F= '/^PORT=/{print $2; exit}' "$env_local" | tr -d '[:space:]')" + q="$(awk -F= '/^WORKER_PROXY_PORT=/{print $2; exit}' "$env_local" | tr -d '[:space:]')" + [[ -n "$p" && "$p" =~ ^[0-9]+$ && "$p" -gt "$highest_port" ]] && highest_port="$p" + [[ -n "$q" && "$q" =~ ^[0-9]+$ && "$q" -gt "$highest_proxy" ]] && highest_proxy="$q" +done +shopt -u nullglob + +if [[ -f "$worktree_dir/.env.local" ]] \ + && existing_port="$(awk -F= '/^PORT=/{print $2; exit}' "$worktree_dir/.env.local" | tr -d '[:space:]')" \ + && [[ "$existing_port" =~ ^[0-9]+$ ]]; then + port="$existing_port" + proxy="$(awk -F= '/^WORKER_PROXY_PORT=/{print $2; exit}' "$worktree_dir/.env.local" | tr -d '[:space:]')" + [[ "$proxy" =~ ^[0-9]+$ ]] || proxy=$((highest_proxy + 1)) +else + port=$((highest_port + 1)) + proxy=$((highest_proxy + 1)) +fi + +cat > "$worktree_dir/.env.local" < "$worktree_dir/.task" + +# Auto-activate this worktree for the Chrome extension / Mac app (symlink swap). +# Safe / idempotent — task-use.sh re-points existing symlinks each time. +"$repo/scripts/task-use.sh" "$name" || true + +# Register the worktree as a Lobu CLI context so the Mac menubar can spawn +# `lobu run` for it (lifecycle: managed) against the worktree's source. Safe to +# re-run — `lobu context add` overwrites the existing entry. Non-fatal: the +# worktree is still useful even if the lobu CLI isn't on PATH yet. +if command -v lobu >/dev/null 2>&1; then + if lobu context add "$name" \ + --api-url "http://localhost:$port" \ + --port "$port" \ + --cwd "$worktree_dir" \ + --lifecycle managed >/dev/null; then + echo "→ registered Lobu context '$name' (menubar can spawn its server)" + else + echo "warning: failed to register Lobu context '$name'" >&2 + fi +else + echo "warning: 'lobu' CLI not on PATH; skipping context registration" >&2 +fi + +cat < # use the worktree at .claude/worktrees/ +# scripts/task-use.sh main # use the main checkout (no worktree) +# +# Symlinks (created/updated each run): +# ~/.config/lobu-dev/active/chrome → /packages/owletto/apps/chrome +# ~/.config/lobu-dev/active/mac → /packages/owletto/apps/mac +# +# Point Chrome's "Load unpacked" at ~/.config/lobu-dev/active/chrome ONCE. +# Open Xcode against ~/.config/lobu-dev/active/mac. Then `task-use ` +# swaps which worktree those resolve to; reload the extension / re-open the +# Xcode project to pick up the new source. + +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage +name="$1" + +if [[ "$name" != "main" ]] && ! [[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "error: name must be kebab-case or 'main': '$name'" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" + +if [[ "$name" == "main" ]]; then + source_root="$repo" +else + source_root="$repo/.claude/worktrees/$name" + if [[ ! -d "$source_root" ]]; then + echo "error: no worktree at $source_root" >&2 + exit 1 + fi +fi + +active_dir="$HOME/.config/lobu-dev/active" +mkdir -p "$active_dir" + +set_link() { + local label="$1" target="$2" link="$3" + if [[ -d "$target" ]]; then + ln -sfn "$target" "$link" + echo "→ $label: $link → $target" + else + # Source path missing in this worktree (e.g. submodule not initialized). + # Leave the existing symlink alone rather than break it. + echo "(skip $label: source not present at $target)" + fi +} + +set_link "chrome" "$source_root/packages/owletto/apps/chrome" "$active_dir/chrome" +set_link "mac" "$source_root/packages/owletto/apps/mac" "$active_dir/mac" + +# Record the active task name for tooling that wants it (and for task-clean +# to know whether to reset to 'main' when cleaning the active worktree). +echo "$name" > "$active_dir/.active-name" +echo "✓ active worktree: $name" +echo "" +echo " ↳ Chrome will not auto-reload the extension when the symlink retargets." +echo " Click 'Reload' on the owletto extension in chrome://extensions to pick" +echo " up the new source. (MV3 service workers re-register on extension reload," +echo " not on symlink change.)"