mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
3 Commits
fix/cli-lo
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae46ae6b3a | ||
|
|
9e7d5bc106 | ||
|
|
91fbb16749 |
@@ -323,45 +323,6 @@ request payload.
|
||||
`path: "download_desktop"` signals Step 3 path choice specifically,
|
||||
not actual download start.
|
||||
|
||||
- `onboarding_runtime_detected` — fired from
|
||||
`packages/views/onboarding/steps/step-runtime-connect.tsx` (desktop
|
||||
Step 3) once per mount, when the scanning phase resolves — either
|
||||
immediately on first runtime registration, or after the 5 s empty
|
||||
timeout. Answers the question "did the user have any AI CLI
|
||||
installed on this machine when they hit Step 3" — currently
|
||||
unanswerable from the existing funnel because the bundled daemon
|
||||
fails to register at all when zero CLIs are on PATH, so
|
||||
`runtime_registered` is silent on that cohort. Splits
|
||||
`completion_path=runtime_skipped` into "had CLIs, skipped anyway"
|
||||
vs "no CLIs available, had no choice". Properties:
|
||||
- `source`: `step3_desktop` (literal; reserved for a future web
|
||||
emission under a different value).
|
||||
- `outcome`: `found` (at least one runtime registered before the
|
||||
5 s grace window expired) or `empty` (none registered by then).
|
||||
- `runtime_count`: number of runtimes visible to this user at
|
||||
resolution time.
|
||||
- `online_count`: subset of `runtime_count` whose `status` is
|
||||
`online`.
|
||||
- `providers`: sorted array of distinct provider names (e.g.
|
||||
`["claude", "codex"]`).
|
||||
- `has_claude` / `has_codex` / `has_cursor`: convenience booleans
|
||||
derived from `providers` for funnel breakdowns without array
|
||||
filtering in HogQL.
|
||||
- `detect_ms`: wall-clock ms from component mount to resolution.
|
||||
Surfaces daemon boot latency — `found` events with a high
|
||||
`detect_ms` approach the timeout threshold and inform whether
|
||||
to lengthen the grace period.
|
||||
|
||||
Person properties set with `$set`:
|
||||
- `has_any_cli`: boolean — cohort signal for "user has at least
|
||||
one local AI CLI detected on this machine".
|
||||
- `detected_cli_count`: number — granular cohort signal.
|
||||
|
||||
Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web
|
||||
users don't run the bundled daemon, so their runtime list reflects
|
||||
daemons from other machines and would corrupt the
|
||||
"CLI installed locally" signal.
|
||||
|
||||
- `download_intent_expressed` — fired whenever a user clicks a CTA
|
||||
that points at the `/download` page. Surfaces five sources across
|
||||
the funnel, letting the top-of-funnel entry be split cleanly.
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
|
||||
const mockListSkills = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeListOptions = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeLocalSkillsOptions = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
@@ -18,6 +20,12 @@ vi.mock("@multica/core/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeListOptions: (...args: unknown[]) => mockRuntimeListOptions(...args),
|
||||
runtimeLocalSkillsOptions: (...args: unknown[]) =>
|
||||
mockRuntimeLocalSkillsOptions(...args),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
@@ -71,35 +79,61 @@ function renderSkillsTab() {
|
||||
describe("SkillsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockListSkills.mockResolvedValue([]);
|
||||
mockRuntimeListOptions.mockReturnValue({
|
||||
queryKey: ["runtimes", "ws-1", "list"],
|
||||
queryFn: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "runtime-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: "daemon-1",
|
||||
name: "Claude (MacBook)",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: "user-1",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
updated_at: "2026-04-16T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
});
|
||||
mockRuntimeLocalSkillsOptions.mockReturnValue({
|
||||
queryKey: ["runtimes", "local-skills", "runtime-1"],
|
||||
queryFn: () =>
|
||||
Promise.resolve({
|
||||
supported: true,
|
||||
skills: [
|
||||
{
|
||||
key: "review-helper",
|
||||
name: "Review Helper",
|
||||
description: "Review pull requests",
|
||||
provider: "claude",
|
||||
source_path: "~/.claude/skills/review-helper",
|
||||
file_count: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render the inline Local Runtime Skills section even for local-runtime agents", async () => {
|
||||
// The inline section auto-loaded local skills on every Skills-tab
|
||||
// entry, which was both noisy and (under multi-replica deploys) prone
|
||||
// to "request not found" because the request store is in-process.
|
||||
// Local-skill import now lives behind the explicit Skills page →
|
||||
// Add Skill → From Runtime tab; nothing here may auto-load.
|
||||
it("shows runtime local skills for local agents", async () => {
|
||||
renderSkillsTab();
|
||||
|
||||
// Top informational callout should still render; that's how we know
|
||||
// the tab body itself rendered (not stuck in a loading state).
|
||||
expect(
|
||||
await screen.findByText(/Local runtime skills are always available/i),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Local Runtime Skills")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Review Helper")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude")).toBeInTheDocument();
|
||||
expect(screen.getByText("~/.claude/skills/review-helper")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Import to Workspace/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Import From Runtime/i })).not.toBeInTheDocument();
|
||||
|
||||
// The removed section's heading and its trigger button must be gone.
|
||||
expect(screen.queryByText("Local Runtime Skills")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Import to Workspace/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// No runtime list / local-skills query should be wired up either —
|
||||
// we removed @multica/core/runtimes from this file's imports.
|
||||
// Surface it via behaviour: the `agent` here has runtime_id but the
|
||||
// tab must not invoke any runtime-list mock to render. (Both are
|
||||
// already deleted from the mock setup above; this assertion is
|
||||
// implicit — the test file would fail to import if the component
|
||||
// still referenced runtimeListOptions / runtimeLocalSkillsOptions.)
|
||||
await waitFor(() => {
|
||||
expect(mockRuntimeLocalSkillsOptions).toHaveBeenCalledWith("runtime-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FileText, Trash2, Info } from "lucide-react";
|
||||
import { Plus, FileText, Trash2, Info, Download, AlertCircle } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,7 +16,10 @@ import { toast } from "sonner";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions, runtimeLocalSkillsOptions } from "@multica/core/runtimes";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RuntimeLocalSkillImportDialog } from "../../../skills/components/runtime-local-skill-import-dialog";
|
||||
import { RuntimeLocalSkillRow } from "../../../skills/components/runtime-local-skill-row";
|
||||
|
||||
export function SkillsTab({
|
||||
agent,
|
||||
@@ -26,11 +29,22 @@ export function SkillsTab({
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [showRuntimeImport, setShowRuntimeImport] = useState(false);
|
||||
const [runtimeImportSkillKey, setRuntimeImportSkillKey] = useState<string | null>(null);
|
||||
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableSkills = workspaceSkills.filter((s) => !agentSkillIds.has(s.id));
|
||||
const runtime = runtimes.find((item) => item.id === agent.runtime_id);
|
||||
const localSkillsQuery = useQuery({
|
||||
...runtimeLocalSkillsOptions(runtime?.id ?? null),
|
||||
enabled:
|
||||
agent.runtime_mode === "local" &&
|
||||
!!runtime?.id &&
|
||||
runtime.status === "online",
|
||||
});
|
||||
|
||||
const handleAdd = async (skillId: string) => {
|
||||
setSaving(true);
|
||||
@@ -137,6 +151,77 @@ export function SkillsTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.runtime_mode === "local" && (
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Local Runtime Skills</h4>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Browse local skills discovered from this runtime. They are read-only here until imported into the workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!runtime ? (
|
||||
<div className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
Runtime details are unavailable for this agent right now.
|
||||
</div>
|
||||
) : runtime.status !== "online" ? (
|
||||
<div className="flex items-start gap-2 rounded-md bg-warning/10 px-3 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
Runtime must be online to browse local skills.
|
||||
</div>
|
||||
) : localSkillsQuery.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="rounded-lg border px-4 py-3">
|
||||
<div className="h-4 w-36 rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-52 rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : localSkillsQuery.error ? (
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{localSkillsQuery.error instanceof Error
|
||||
? localSkillsQuery.error.message
|
||||
: "Failed to load runtime local skills"}
|
||||
</div>
|
||||
) : !localSkillsQuery.data?.supported ? (
|
||||
<div className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
This runtime provider does not expose local skill inventory yet.
|
||||
</div>
|
||||
) : (localSkillsQuery.data.skills ?? []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local skills found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add local skills to this runtime first, then import the ones you want to share.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(localSkillsQuery.data.skills ?? []).map((skill) => (
|
||||
<RuntimeLocalSkillRow
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setRuntimeImportSkillKey(skill.key);
|
||||
setShowRuntimeImport(true);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Import to Workspace
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill Picker Dialog */}
|
||||
{showPicker && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setShowPicker(false); }}>
|
||||
@@ -181,6 +266,15 @@ export function SkillsTab({
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{showRuntimeImport && runtime && (
|
||||
<RuntimeLocalSkillImportDialog
|
||||
open={showRuntimeImport}
|
||||
onClose={() => setShowRuntimeImport(false)}
|
||||
fixedRuntimeId={runtime.id}
|
||||
initialRuntimeId={runtime.id}
|
||||
initialSkillKey={runtimeImportSkillKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
// Hoisted mocks — replace analytics and the runtime picker before the SUT
|
||||
// imports them. Tests drive picker state via `mocks.pickerState`; every
|
||||
// captureEvent / setPersonProperties call lands on `mocks.captureEvent` /
|
||||
// `mocks.setPersonProperties` so we can assert the payload shape.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureEvent: vi.fn<(name: string, props?: Record<string, unknown>) => void>(),
|
||||
setPersonProperties: vi.fn<(props: Record<string, unknown>) => void>(),
|
||||
pickerState: {
|
||||
runtimes: [] as AgentRuntime[],
|
||||
selected: null as AgentRuntime | null,
|
||||
selectedId: null as string | null,
|
||||
setSelectedId: vi.fn<(id: string) => void>(),
|
||||
hasRuntimes: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
captureEvent: mocks.captureEvent,
|
||||
setPersonProperties: mocks.setPersonProperties,
|
||||
}));
|
||||
|
||||
vi.mock("../components/use-runtime-picker", () => ({
|
||||
useRuntimePicker: () => mocks.pickerState,
|
||||
}));
|
||||
|
||||
import { StepRuntimeConnect } from "./step-runtime-connect";
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
return {
|
||||
id: "rt_test",
|
||||
workspace_id: "ws_test",
|
||||
name: "Claude Code",
|
||||
provider: "claude",
|
||||
status: "online",
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
daemon_id: null,
|
||||
last_seen_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as unknown as AgentRuntime;
|
||||
}
|
||||
|
||||
function setPicker(patch: Partial<typeof mocks.pickerState> = {}) {
|
||||
mocks.pickerState.runtimes = patch.runtimes ?? [];
|
||||
mocks.pickerState.selected = patch.selected ?? null;
|
||||
mocks.pickerState.selectedId = patch.selectedId ?? null;
|
||||
mocks.pickerState.hasRuntimes = patch.hasRuntimes ?? false;
|
||||
mocks.pickerState.setSelectedId = vi.fn();
|
||||
}
|
||||
|
||||
function renderStep() {
|
||||
const onNext = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
render(
|
||||
<StepRuntimeConnect wsId="ws_test" onNext={onNext} onBack={onBack} />,
|
||||
);
|
||||
return { onNext, onBack };
|
||||
}
|
||||
|
||||
describe("StepRuntimeConnect — onboarding_runtime_detected", () => {
|
||||
beforeEach(() => {
|
||||
mocks.captureEvent.mockReset();
|
||||
mocks.setPersonProperties.mockReset();
|
||||
setPicker();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fires `outcome: found` when runtimes arrive synchronously on mount", () => {
|
||||
const rt = makeRuntime({
|
||||
id: "rt_claude",
|
||||
provider: "claude",
|
||||
status: "online",
|
||||
});
|
||||
setPicker({ runtimes: [rt], selected: rt, selectedId: rt.id, hasRuntimes: true });
|
||||
|
||||
renderStep();
|
||||
|
||||
expect(mocks.captureEvent).toHaveBeenCalledTimes(1);
|
||||
const [name, props] = mocks.captureEvent.mock.calls[0]!;
|
||||
expect(name).toBe("onboarding_runtime_detected");
|
||||
expect(props).toMatchObject({
|
||||
source: "step3_desktop",
|
||||
outcome: "found",
|
||||
runtime_count: 1,
|
||||
online_count: 1,
|
||||
providers: ["claude"],
|
||||
has_claude: true,
|
||||
has_codex: false,
|
||||
has_cursor: false,
|
||||
});
|
||||
expect(typeof (props as Record<string, unknown>).detect_ms).toBe("number");
|
||||
|
||||
expect(mocks.setPersonProperties).toHaveBeenCalledWith({
|
||||
has_any_cli: true,
|
||||
detected_cli_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives has_claude / has_codex / has_cursor from distinct providers", () => {
|
||||
setPicker({
|
||||
runtimes: [
|
||||
makeRuntime({ id: "rt1", provider: "claude" }),
|
||||
makeRuntime({ id: "rt2", provider: "codex", status: "offline" }),
|
||||
makeRuntime({ id: "rt3", provider: "cursor" }),
|
||||
],
|
||||
hasRuntimes: true,
|
||||
});
|
||||
|
||||
renderStep();
|
||||
|
||||
expect(mocks.captureEvent).toHaveBeenCalledTimes(1);
|
||||
const props = mocks.captureEvent.mock.calls[0]![1] as Record<string, unknown>;
|
||||
expect(props.runtime_count).toBe(3);
|
||||
expect(props.online_count).toBe(2);
|
||||
expect(props.providers).toEqual(["claude", "codex", "cursor"]);
|
||||
expect(props.has_claude).toBe(true);
|
||||
expect(props.has_codex).toBe(true);
|
||||
expect(props.has_cursor).toBe(true);
|
||||
});
|
||||
|
||||
it("fires `outcome: empty` after the 5s scanning timeout when no runtimes arrive", () => {
|
||||
setPicker({ runtimes: [] });
|
||||
|
||||
renderStep();
|
||||
|
||||
// Scanning phase: no event yet.
|
||||
expect(mocks.captureEvent).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past the 5s empty-timeout inside act so the state flip
|
||||
// flushes React updates before we assert.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5_001);
|
||||
});
|
||||
|
||||
expect(mocks.captureEvent).toHaveBeenCalledTimes(1);
|
||||
const props = mocks.captureEvent.mock.calls[0]![1] as Record<string, unknown>;
|
||||
expect(props).toMatchObject({
|
||||
source: "step3_desktop",
|
||||
outcome: "empty",
|
||||
runtime_count: 0,
|
||||
online_count: 0,
|
||||
providers: [],
|
||||
has_claude: false,
|
||||
has_codex: false,
|
||||
has_cursor: false,
|
||||
});
|
||||
|
||||
expect(mocks.setPersonProperties).toHaveBeenCalledWith({
|
||||
has_any_cli: false,
|
||||
detected_cli_count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-emit if the component re-renders after resolution", () => {
|
||||
const rt = makeRuntime({ id: "rt_claude", provider: "claude" });
|
||||
setPicker({ runtimes: [rt], selected: rt, selectedId: rt.id, hasRuntimes: true });
|
||||
|
||||
const { onNext } = renderStep();
|
||||
expect(mocks.captureEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate a runtime coming online / a second runtime registering:
|
||||
// the event has already resolved once; it must not re-emit.
|
||||
setPicker({
|
||||
runtimes: [rt, makeRuntime({ id: "rt_codex", provider: "codex" })],
|
||||
selected: rt,
|
||||
selectedId: rt.id,
|
||||
hasRuntimes: true,
|
||||
});
|
||||
// Force a re-render by firing a timer tick — React will re-read the
|
||||
// mocked picker state but the ref latch keeps the event unique.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
expect(mocks.captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only counts distinct providers (multiple runtimes of the same provider)", () => {
|
||||
setPicker({
|
||||
runtimes: [
|
||||
makeRuntime({ id: "rt1", provider: "claude" }),
|
||||
makeRuntime({ id: "rt2", provider: "claude", status: "offline" }),
|
||||
],
|
||||
hasRuntimes: true,
|
||||
});
|
||||
|
||||
renderStep();
|
||||
|
||||
const props = mocks.captureEvent.mock.calls[0]![1] as Record<string, unknown>;
|
||||
expect(props.runtime_count).toBe(2);
|
||||
expect(props.online_count).toBe(1);
|
||||
expect(props.providers).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("mounts without touching framework-level globals", () => {
|
||||
// Sanity: the StepHeader renders and the DragStrip doesn't explode
|
||||
// under jsdom. Keeps the test file honest if someone refactors the
|
||||
// shell around the effect.
|
||||
setPicker({ runtimes: [] });
|
||||
renderStep();
|
||||
expect(screen.getByText(/Looking for your tools/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
|
||||
import { captureEvent, setPersonProperties } from "@multica/core/analytics";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -113,50 +112,6 @@ function FancyView({
|
||||
|
||||
const onlineCount = runtimes.filter((r) => r.status === "online").length;
|
||||
|
||||
// One-shot analytics event when the scan window resolves. Answers the
|
||||
// question "did the user actually have any AI CLI installed on this
|
||||
// machine when they hit Step 3" — currently unanswerable from the
|
||||
// existing funnel because a zero-CLI daemon fails to register at all,
|
||||
// so `runtime_registered` is silent on that cohort. Emitting from here
|
||||
// (rather than the daemon) keeps the signal in sync with what the UI
|
||||
// actually showed the user: "scanning → found" vs "scanning → empty"
|
||||
// after the 5s grace period.
|
||||
const detectStartRef = useRef<number | null>(null);
|
||||
if (detectStartRef.current === null) {
|
||||
detectStartRef.current =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
const detectedEmittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (detectedEmittedRef.current) return;
|
||||
if (phase === "scanning") return;
|
||||
detectedEmittedRef.current = true;
|
||||
|
||||
const providers = Array.from(
|
||||
new Set(runtimes.map((r) => r.provider).filter(Boolean)),
|
||||
).sort();
|
||||
const now =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
const detectMs = Math.round(now - (detectStartRef.current ?? now));
|
||||
|
||||
captureEvent("onboarding_runtime_detected", {
|
||||
source: "step3_desktop",
|
||||
outcome: phase,
|
||||
runtime_count: runtimes.length,
|
||||
online_count: onlineCount,
|
||||
providers,
|
||||
has_claude: providers.includes("claude"),
|
||||
has_codex: providers.includes("codex"),
|
||||
has_cursor: providers.includes("cursor"),
|
||||
detect_ms: detectMs,
|
||||
});
|
||||
|
||||
setPersonProperties({
|
||||
has_any_cli: runtimes.length > 0,
|
||||
detected_cli_count: runtimes.length,
|
||||
});
|
||||
}, [phase, runtimes, onlineCount]);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Cloud waitlist submission state lives here (rather than in EmptyView)
|
||||
// so it survives phase flips — e.g. a runtime coming online after the
|
||||
|
||||
@@ -37,11 +37,6 @@ var authLogoutCmd = &cobra.Command{
|
||||
RunE: runAuthLogout,
|
||||
}
|
||||
|
||||
// callbackHostFlag lets users override the host/IP that goes into the OAuth
|
||||
// cli_callback URL. Useful when the CLI sits behind a reverse proxy or the
|
||||
// auto-detected LAN IP isn't the one the browser can reach.
|
||||
const callbackHostFlag = "callback-host"
|
||||
|
||||
func init() {
|
||||
authCmd.AddCommand(authStatusCmd)
|
||||
authCmd.AddCommand(authLogoutCmd)
|
||||
@@ -99,114 +94,28 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error {
|
||||
return runAuthLoginBrowser(cmd)
|
||||
}
|
||||
|
||||
// resolveCallbackBinding picks the host that goes into the `cli_callback`
|
||||
// URL and the interface the CLI should bind its local HTTP listener to.
|
||||
//
|
||||
// The browser running the login flow is on the *server's* machine (or
|
||||
// wherever the user clicked the link), not on the CLI host. That means the
|
||||
// callback URL must resolve to an address the browser can actually reach,
|
||||
// which is different in each topology:
|
||||
//
|
||||
// - hosted / public app URL: browser and CLI are on the same machine,
|
||||
// localhost works.
|
||||
// - self-host, CLI on server box: same as above.
|
||||
// - self-host, CLI on a different LAN box: the callback URL must point at
|
||||
// the CLI's own LAN IP, not the server's.
|
||||
// - reverse-proxied / FQDN setups: auto-detection can't know the right
|
||||
// host — the user supplies it via --callback-host.
|
||||
//
|
||||
// detectOutbound is injected so tests can exercise the routing decisions
|
||||
// without real network calls.
|
||||
func resolveCallbackBinding(flagHost, serverURL, appURL string, detectOutbound func(string) net.IP) (callbackHost, bindAddr string) {
|
||||
// Explicit flag always wins. Bind on all interfaces so the browser can
|
||||
// reach us regardless of which interface the host name resolves to.
|
||||
if h := strings.TrimSpace(flagHost); h != "" {
|
||||
return h, "0.0.0.0"
|
||||
}
|
||||
|
||||
appIP := urlPrivateIP(appURL)
|
||||
if appIP == nil {
|
||||
// Public hostname, FQDN without private-IP mapping, or parse error.
|
||||
// Loopback is the only safe default — on hosted/public setups the
|
||||
// browser and CLI live on the same machine.
|
||||
return "localhost", "127.0.0.1"
|
||||
}
|
||||
|
||||
// app_url is a private LAN IP. Figure out whether the CLI is on that
|
||||
// same box or a different one by asking the kernel which local address
|
||||
// it would use to reach the server. Same box → loopback is fine.
|
||||
// Different box → use the CLI's outbound IP so the browser can reach us.
|
||||
cliIP := detectOutbound(serverURL)
|
||||
if cliIP == nil {
|
||||
// Detection failed (offline, unreachable server, etc.). Fall back to
|
||||
// the app IP — preserves the pre-existing same-machine behaviour.
|
||||
return appIP.String(), "0.0.0.0"
|
||||
}
|
||||
if cliIP.Equal(appIP) {
|
||||
return "localhost", "127.0.0.1"
|
||||
}
|
||||
return cliIP.String(), "0.0.0.0"
|
||||
}
|
||||
|
||||
// urlPrivateIP returns the hostname of rawURL parsed as an RFC 1918 IP, or
|
||||
// nil if the URL is unparsable or the host is not a private literal.
|
||||
func urlPrivateIP(rawURL string) net.IP {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(parsed.Hostname())
|
||||
if ip == nil || !ip.IsPrivate() {
|
||||
return nil
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// detectOutboundIP returns the local IPv4 address the OS would use to reach
|
||||
// serverURL, or nil if detection fails. The UDP dial does not send packets —
|
||||
// it just causes the kernel to pick a source IP for the destination route.
|
||||
func detectOutboundIP(serverURL string) net.IP {
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return nil
|
||||
}
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
if parsed.Scheme == "https" {
|
||||
port = "443"
|
||||
} else {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
conn, err := net.Dial("udp4", net.JoinHostPort(parsed.Hostname(), port))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer conn.Close()
|
||||
local, ok := conn.LocalAddr().(*net.UDPAddr)
|
||||
if !ok || local.IP == nil {
|
||||
return nil
|
||||
}
|
||||
// Normalise to 4-byte form so Equal() comparisons match net.ParseIP
|
||||
// output consistently.
|
||||
if v4 := local.IP.To4(); v4 != nil {
|
||||
return v4
|
||||
}
|
||||
return local.IP
|
||||
}
|
||||
|
||||
func runAuthLoginBrowser(cmd *cobra.Command) error {
|
||||
serverURL := resolveServerURL(cmd)
|
||||
appURL := resolveAppURL(cmd)
|
||||
|
||||
flagHost, _ := cmd.Flags().GetString(callbackHostFlag)
|
||||
callbackHost, bindAddr := resolveCallbackBinding(flagHost, serverURL, appURL, detectOutboundIP)
|
||||
// Determine the callback host from the configured app URL.
|
||||
// For self-hosted setups where the browser is on a different machine
|
||||
// (e.g. Multica running on a LAN server), use the server's private IP
|
||||
// so the browser can reach the CLI's local HTTP server.
|
||||
// For production (public hostnames like multica.ai), keep localhost —
|
||||
// the browser and CLI are on the same machine.
|
||||
callbackHost := "localhost"
|
||||
bindAddr := "127.0.0.1"
|
||||
if parsed, err := url.Parse(appURL); err == nil {
|
||||
h := parsed.Hostname()
|
||||
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
|
||||
callbackHost = h
|
||||
bindAddr = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// Pin to "tcp4" — a bare "tcp" on macOS can produce an IPv6-only socket
|
||||
// that IPv4 clients (including browsers resolving localhost → 127.0.0.1)
|
||||
// cannot reach. The callback URL is always an IPv4 literal or hostname,
|
||||
// so an IPv4 listener is what the browser actually needs.
|
||||
listener, err := net.Listen("tcp4", bindAddr+":0")
|
||||
// Start a local HTTP server on a random port to receive the callback.
|
||||
listener, err := net.Listen("tcp", bindAddr+":0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start local server: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -37,88 +36,6 @@ func TestResolveAppURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveCallbackBinding(t *testing.T) {
|
||||
// Fake outbound detector: pretends the CLI has a fixed LAN IP regardless
|
||||
// of which server it dials.
|
||||
fixed := func(ip string) func(string) net.IP {
|
||||
return func(string) net.IP { return net.ParseIP(ip).To4() }
|
||||
}
|
||||
failing := func(string) net.IP { return nil }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
flagHost string
|
||||
serverURL string
|
||||
appURL string
|
||||
detect func(string) net.IP
|
||||
wantCallback string
|
||||
wantBind string
|
||||
}{
|
||||
{
|
||||
name: "public app URL stays on loopback",
|
||||
appURL: "https://multica.ai",
|
||||
serverURL: "https://api.multica.ai",
|
||||
detect: failing,
|
||||
wantCallback: "localhost",
|
||||
wantBind: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "localhost app URL stays on loopback",
|
||||
appURL: "http://localhost:3000",
|
||||
serverURL: "http://localhost:8080",
|
||||
detect: failing,
|
||||
wantCallback: "localhost",
|
||||
wantBind: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "same-machine self-host uses loopback (CLI IP matches app IP)",
|
||||
appURL: "http://192.168.0.28:3000",
|
||||
serverURL: "http://192.168.0.28:8080",
|
||||
detect: fixed("192.168.0.28"),
|
||||
wantCallback: "localhost",
|
||||
wantBind: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "cross-machine self-host points callback at CLI's LAN IP",
|
||||
appURL: "http://192.168.0.28:3000",
|
||||
serverURL: "http://192.168.0.28:8080",
|
||||
detect: fixed("192.168.0.47"),
|
||||
wantCallback: "192.168.0.47",
|
||||
wantBind: "0.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "outbound detection failure falls back to app IP",
|
||||
appURL: "http://192.168.0.28:3000",
|
||||
serverURL: "http://192.168.0.28:8080",
|
||||
detect: failing,
|
||||
wantCallback: "192.168.0.28",
|
||||
wantBind: "0.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "--callback-host flag overrides everything",
|
||||
flagHost: "cli.internal.example",
|
||||
appURL: "https://multica.ai",
|
||||
serverURL: "https://api.multica.ai",
|
||||
detect: fixed("10.0.0.5"),
|
||||
wantCallback: "cli.internal.example",
|
||||
wantBind: "0.0.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotCallback, gotBind := resolveCallbackBinding(tc.flagHost, tc.serverURL, tc.appURL, tc.detect)
|
||||
if gotCallback != tc.wantCallback {
|
||||
t.Errorf("callback host = %q, want %q", gotCallback, tc.wantCallback)
|
||||
}
|
||||
if gotBind != tc.wantBind {
|
||||
t.Errorf("bind addr = %q, want %q", gotBind, tc.wantBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAPIBaseURL(t *testing.T) {
|
||||
t.Run("converts websocket base URL", func(t *testing.T) {
|
||||
if got := normalizeAPIBaseURL("ws://localhost:18106/ws"); got != "http://localhost:18106" {
|
||||
|
||||
@@ -37,7 +37,6 @@ var loginCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
loginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token")
|
||||
loginCmd.Flags().String(callbackHostFlag, "", "Host the OAuth callback URL points at (auto-detected from the server's route when empty). Use this for reverse-proxy / FQDN setups where auto-detection picks the wrong interface.")
|
||||
}
|
||||
|
||||
func runLogin(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -48,10 +46,6 @@ var setupSelfHostCmd = &cobra.Command{
|
||||
By default, connects to http://localhost:8080 (backend) and http://localhost:3000 (frontend).
|
||||
Use --server-url and --app-url to specify a custom server (e.g. an on-premise deployment).
|
||||
|
||||
If you run this command from a different machine than the server, also pass
|
||||
--callback-host <FQDN-or-IP-the-browser-can-reach-back-to-this-machine-on> so
|
||||
the OAuth login flow can return the token to the CLI.
|
||||
|
||||
Examples:
|
||||
multica setup self-host
|
||||
multica setup self-host --server-url https://api.internal.co --app-url https://app.internal.co
|
||||
@@ -64,7 +58,6 @@ func init() {
|
||||
setupSelfHostCmd.Flags().String("app-url", "", "Frontend app URL (e.g. https://app.internal.co)")
|
||||
setupSelfHostCmd.Flags().Int("port", 8080, "Backend server port (used when --server-url is not set)")
|
||||
setupSelfHostCmd.Flags().Int("frontend-port", 3000, "Frontend port (used when --app-url is not set)")
|
||||
setupSelfHostCmd.Flags().String(callbackHostFlag, "", "Host the OAuth callback URL points at (auto-detected when empty). Use this for reverse-proxy / FQDN setups.")
|
||||
|
||||
setupCmd.AddCommand(setupCloudCmd)
|
||||
setupCmd.AddCommand(setupSelfHostCmd)
|
||||
@@ -166,28 +159,13 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
|
||||
appURL, _ := cmd.Flags().GetString("app-url")
|
||||
port, _ := cmd.Flags().GetInt("port")
|
||||
frontendPort, _ := cmd.Flags().GetInt("frontend-port")
|
||||
userProvidedServerURL := serverURL != ""
|
||||
|
||||
// If custom URLs provided, use them; otherwise default to localhost with ports.
|
||||
if serverURL == "" {
|
||||
serverURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
if appURL == "" {
|
||||
if userProvidedServerURL && !serverHostIsLocal(serverURL) {
|
||||
// We can't guess the frontend URL for a remote server: api.x.co
|
||||
// and app.x.co, or an https-fronted deployment, would silently
|
||||
// produce a broken login URL. Ask the user instead.
|
||||
entered, err := promptAppURL(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entered == "" {
|
||||
return fmt.Errorf("--app-url is required when --server-url points at a remote host (e.g. --app-url https://app.internal.co)")
|
||||
}
|
||||
appURL = entered
|
||||
} else {
|
||||
appURL = fmt.Sprintf("http://localhost:%d", frontendPort)
|
||||
}
|
||||
appURL = fmt.Sprintf("http://localhost:%d", frontendPort)
|
||||
}
|
||||
|
||||
cfg := cli.CLIConfig{
|
||||
@@ -225,39 +203,6 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverHostIsLocal reports whether serverURL points at the same machine as
|
||||
// the CLI (loopback literal or "localhost"). Used to decide whether to infer
|
||||
// app_url from server_url or fall back to the local-dev default.
|
||||
func serverHostIsLocal(serverURL string) bool {
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
h := parsed.Hostname()
|
||||
if h == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(h); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// promptAppURL asks the user for the frontend URL interactively. We can't
|
||||
// derive it from a remote server_url — api.example.com ≠ app.example.com in
|
||||
// most production setups — so guessing would just defer the failure to the
|
||||
// browser login step. Returns an empty string if the user hits enter.
|
||||
func promptAppURL(serverURL string) (string, error) {
|
||||
fmt.Fprintf(os.Stderr, "No --app-url provided, and --server-url (%s) is remote.\n", serverURL)
|
||||
fmt.Fprint(os.Stderr, "Enter the frontend app URL (e.g. https://app.internal.co): ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(line), "/"), nil
|
||||
}
|
||||
|
||||
// probeServer checks whether a Multica backend is reachable at the given URL.
|
||||
func probeServer(baseURL string) bool {
|
||||
url := strings.TrimRight(baseURL, "/") + "/health"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestServerHostIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
server string
|
||||
want bool
|
||||
}{
|
||||
{"localhost", "http://localhost:8080", true},
|
||||
{"127.0.0.1", "http://127.0.0.1:8080", true},
|
||||
{"IPv6 loopback", "http://[::1]:8080", true},
|
||||
{"LAN IP", "http://192.168.0.28:8080", false},
|
||||
{"public FQDN", "https://api.internal.co", false},
|
||||
{"unparseable", "://bad", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := serverHostIsLocal(tc.server); got != tc.want {
|
||||
t.Errorf("serverHostIsLocal(%q) = %v, want %v", tc.server, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user