Compare commits

..

3 Commits

Author SHA1 Message Date
Lambda
ae46ae6b3a fix(landing): align changelog nav day/version columns
Reserve a fixed-width right-aligned slot for the day number so
single-digit days (e.g. "1", "9") don't shift the version column.
2026-04-23 15:50:22 +08:00
Lambda
9e7d5bc106 feat(landing): move changelog date nav to left as timeline sidebar
Moves the date navigation from the right to the left and restyles it
as a grouped timeline:

- Releases are grouped under a month-year header ("April 2026").
- A vertical rail connects a dot per release; the active dot is filled
  with a soft halo ring, the row text goes full-opacity + semibold.
- Clicking a date smooth-scrolls to the release and pins the hash; a
  short nav lock suppresses scroll-spy flicker while the page animates.
- Sidebar is sticky up to viewport height, scrollable when there are
  many releases; on <lg the sidebar collapses and content falls back
  to the existing centered layout.
- Entry headers now render the full localized date for clarity.

Label changed from "On this page" / "本页目录" to "All releases" /
"历史版本" to match the new nav-style role.
2026-04-23 15:48:27 +08:00
Lambda
91fbb16749 feat(landing): add sticky date navigation to changelog page
Adds a right-side "On this page" nav that lists every release date and
scroll-spies the active entry as the user reads through the changelog.
Dates are formatted per locale (e.g. "April 22" / "4月22日").
2026-04-23 15:29:35 +08:00
10 changed files with 172 additions and 601 deletions

View File

@@ -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.

View File

@@ -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");
});
});
});

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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" {

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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)
}
})
}
}