Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
22 changes: 21 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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=<name> - Create a paired worktree at .claude/worktrees/<name> (lobu + submodule on real branch, .env copied, ports auto-assigned, Lobu context registered)"
@echo " make task-clean NAME=<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=<name|main> - 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.
Expand Down Expand Up @@ -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=<kebab-case-name>}
@./scripts/task-setup.sh "$(NAME)"

task-clean:
@: $${NAME?Usage: make task-clean NAME=<name> [FORCE=1]}
@./scripts/task-clean.sh "$(NAME)" $$( [ "$(FORCE)" = "1" ] && echo --force )

task-use:
@: $${NAME?Usage: make task-use NAME=<name|main>}
@./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.
Expand Down
27 changes: 26 additions & 1 deletion packages/cli/src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const config = await loadContextConfig();
Expand Down Expand Up @@ -45,13 +47,36 @@ export async function contextCurrentCommand(): Promise<void> {
export async function contextAddCommand(options: {
name: string;
apiUrl: string;
port?: number;
host?: string;
databaseUrl?: string;
dataDir?: string;
cwd?: string;
lifecycle?: "managed" | "external";
}): Promise<void> {
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<void> {
await removeContext(name);
console.log(chalk.dim(`\n Removed context ${name}\n`));
}

export async function contextUseCommand(name: string): Promise<void> {
const trimmedName = name.trim();
const config = await setCurrentContext(trimmedName);
Expand Down
75 changes: 71 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,10 +543,69 @@ Memory:
.command("add <name>")
.description("Add a named context")
.requiredOption("--api-url <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 <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;
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.option("--host <host>", "Server host (default: 127.0.0.1)")
.option(
"--database-url <url>",
"Postgres DATABASE_URL for the managed server"
)
.option(
"--data-dir <path>",
"LOBU_DATA_DIR for the managed server (state, PGlite)"
)
.option(
"--cwd <path>",
"Working directory the lifecycle owner cd's into before spawning `lobu run` (used by per-worktree contexts)"
)
.option(
"--lifecycle <mode>",
"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 <name>")
Expand All @@ -556,6 +615,14 @@ Memory:
await contextUseCommand(name);
});

context
.command("rm <name>")
.description("Remove a named context (idempotent)")
.action(async (name: string) => {
const { contextRmCommand } = await import("./commands/context.js");
await contextRmCommand(name);
});

// ─── status ─────────────────────────────────────────────────────────
withCommonOpts(
program
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/internal/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 44 additions & 2 deletions packages/cli/src/internal/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,17 +179,51 @@ export async function resolveContext(

export async function addContext(
name: string,
apiUrl: string
apiUrl: string,
server?: LobuServerConfig
): Promise<LobuContextConfig> {
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<LobuContextConfig> {
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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
getCurrentContextName,
getMemoryUrl,
loadContextConfig,
removeContext,
resolveContext,
setActiveOrg,
setCurrentContext,
Expand Down
Loading
Loading