mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
10 Commits
v0.2.25
...
fix/projec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1be458788 | ||
|
|
64c605e227 | ||
|
|
820d57535e | ||
|
|
a7299bf857 | ||
|
|
baac4080e9 | ||
|
|
99f6cb8130 | ||
|
|
b5f1e506e5 | ||
|
|
00cde21724 | ||
|
|
1476c268dd | ||
|
|
9a5f5ca498 |
@@ -1,12 +0,0 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
@@ -8,6 +8,8 @@ import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -37,6 +39,10 @@ if (process.platform !== "win32") {
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
};
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
@@ -187,7 +193,25 @@ if (!gotTheLock) {
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
const viteEnv = import.meta.env as ImportMetaEnv & {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
};
|
||||
|
||||
runtimeConfigResult = await loadRuntimeConfig({
|
||||
isDev: is.dev,
|
||||
// electron-vite exposes VITE_* on import.meta.env for the main process;
|
||||
// keep dev URL overrides on the same source the renderer used before
|
||||
// runtime config moved endpoint resolution into main/preload.
|
||||
env: {
|
||||
apiUrl: viteEnv.VITE_API_URL,
|
||||
wsUrl: viteEnv.VITE_WS_URL,
|
||||
appUrl: viteEnv.VITE_APP_URL,
|
||||
},
|
||||
});
|
||||
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
@@ -223,6 +247,13 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
ipcMain.on("runtime-config:get", (event) => {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
90
apps/desktop/src/main/runtime-config-loader.test.ts
Normal file
90
apps/desktop/src/main/runtime-config-loader.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { mkdtemp, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
|
||||
describe("loadRuntimeConfig", () => {
|
||||
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: true,
|
||||
configPath,
|
||||
env: {
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cloud defaults when packaged config is absent", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: false,
|
||||
configPath: join(dir, "missing.json"),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a valid packaged desktop.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://api.example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when packaged desktop.json is invalid", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(configPath, "{");
|
||||
|
||||
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain(configPath);
|
||||
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
|
||||
}
|
||||
});
|
||||
});
|
||||
60
apps/desktop/src/main/runtime-config-loader.ts
Normal file
60
apps/desktop/src/main/runtime-config-loader.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { app } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
type RuntimeConfig,
|
||||
type RuntimeConfigEnv,
|
||||
type RuntimeConfigResult,
|
||||
} from "../shared/runtime-config";
|
||||
|
||||
export async function loadRuntimeConfig(options: {
|
||||
isDev: boolean;
|
||||
env: RuntimeConfigEnv;
|
||||
configPath?: string;
|
||||
}): Promise<RuntimeConfigResult> {
|
||||
if (options.isDev) {
|
||||
try {
|
||||
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: { message: errorMessage(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = options.configPath ?? desktopConfigPath();
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
return { ok: true, config: parseRuntimeConfig(raw) };
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Invalid ${configPath}: ${errorMessage(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopConfigPath(): string {
|
||||
return join(app.getPath("home"), ".multica", "desktop.json");
|
||||
}
|
||||
|
||||
function isMissingFileError(err: unknown): boolean {
|
||||
return Boolean(
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export type { RuntimeConfig, RuntimeConfigResult };
|
||||
3
apps/desktop/src/preload/index.d.ts
vendored
3
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -6,6 +7,8 @@ interface DesktopAPI {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -21,12 +22,30 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
function fetchRuntimeConfig(): RuntimeConfigResult {
|
||||
try {
|
||||
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
|
||||
if (result && typeof result === "object" && "ok" in result) return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: "Runtime config unavailable" } };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
const runtimeConfig = fetchRuntimeConfig();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
|
||||
@@ -30,11 +30,16 @@ function AppContent() {
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
|
||||
? window.desktopAPI.runtimeConfig.config
|
||||
: null;
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
if (!runtimeConfig) return;
|
||||
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
|
||||
}, [runtimeConfig]);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
@@ -226,9 +231,21 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
|
||||
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
|
||||
</p>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
@@ -252,6 +269,7 @@ async function handleDaemonLogout() {
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
@@ -260,14 +278,18 @@ export default function App() {
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
{runtimeConfigResult.ok ? (
|
||||
<CoreProvider
|
||||
apiBaseUrl={runtimeConfigResult.config.apiUrl}
|
||||
wsUrl={runtimeConfigResult.config.wsUrl}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
) : (
|
||||
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
|
||||
)}
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -2,14 +2,23 @@ import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
function requireRuntimeAppUrl(): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const webUrl = requireRuntimeAppUrl();
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
`${webUrl}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
function requireRuntimeAppUrl(scope: string): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
`Invariant violated: ${scope} rendered before App accepted runtime config`,
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -116,6 +120,7 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
@@ -186,9 +191,9 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
[location],
|
||||
[appUrl, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
@@ -211,6 +216,7 @@ export function TabNavigationProvider({
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -246,9 +252,9 @@ export function TabNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
[router, location],
|
||||
[appUrl, router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
99
apps/desktop/src/shared/runtime-config.test.ts
Normal file
99
apps/desktop/src/shared/runtime-config.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
deriveWsUrl,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
} from "./runtime-config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
it("uses cloud defaults without a desktop.json file", () => {
|
||||
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives https/wss compatible URLs from apiUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
|
||||
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ws for http api URLs", () => {
|
||||
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
|
||||
});
|
||||
|
||||
it("accepts explicit appUrl and wsUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com/",
|
||||
wsUrl: "wss://ws.example.com/socket/",
|
||||
appUrl: "https://app.example.com/",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://ws.example.com/socket",
|
||||
appUrl: "https://app.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
|
||||
).toThrow(/schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects non-http api schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
|
||||
).toThrow(/apiUrl must use http or https/);
|
||||
});
|
||||
|
||||
it("rejects non-ws websocket schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "https://api.example.com/ws",
|
||||
}),
|
||||
),
|
||||
).toThrow(/wsUrl must use ws or wss/);
|
||||
});
|
||||
|
||||
it("preserves electron-vite dev env precedence", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "http://dev-api.example.test:8080/",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws/",
|
||||
appUrl: "http://dev-app.example.test:3000/",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://dev-api.example.test:8080",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws",
|
||||
appUrl: "http://dev-app.example.test:3000",
|
||||
});
|
||||
});
|
||||
});
|
||||
157
apps/desktop/src/shared/runtime-config.ts
Normal file
157
apps/desktop/src/shared/runtime-config.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
export interface RuntimeConfig {
|
||||
schemaVersion: 1;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RuntimeConfigResult =
|
||||
| { ok: true; config: RuntimeConfig }
|
||||
| { ok: false; error: RuntimeConfigError };
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
|
||||
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface RuntimeConfigEnv {
|
||||
apiUrl?: string;
|
||||
wsUrl?: string;
|
||||
appUrl?: string;
|
||||
}
|
||||
|
||||
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
const apiUrl = normalizeHttpUrl(
|
||||
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
|
||||
"VITE_API_URL",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl,
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: normalizeHttpUrl(
|
||||
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
|
||||
"VITE_APP_URL",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConfig(raw: string): RuntimeConfig {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid desktop runtime config: expected a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
|
||||
}
|
||||
|
||||
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
|
||||
const appUrl = optionalString(obj.appUrl, "appUrl");
|
||||
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
|
||||
|
||||
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl: normalizedApiUrl,
|
||||
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
|
||||
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveWsUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error("apiUrl must use http or https");
|
||||
url.pathname = joinPath(url.pathname, "/ws");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
export function deriveAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function joinPath(base: string, suffix: string): string {
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}${suffix}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ description: What Multica Desktop is, how it differs from the web app, and when
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. It talks to the same backend as the web app and shows the same data, but it adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
|
||||
## Desktop or web — which to pick
|
||||
|
||||
@@ -66,25 +66,34 @@ Grab the installer for your platform from the [Multica downloads page](https://m
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="warning">
|
||||
**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
|
||||
<Callout type="info">
|
||||
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# Edit apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
Minimal self-host config:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend and connecting with the CLI or Desktop runtime config
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Multica Desktop 是什么、和 Web 有什么区别、什么时候
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端,看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端、看到的数据完全一样。Desktop 默认使用 Multica Cloud;自部署实例可以通过本地运行时配置文件接入。它还给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
|
||||
## Desktop 和 Web 该用哪个
|
||||
|
||||
@@ -66,25 +66,34 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="warning">
|
||||
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
|
||||
<Callout type="info">
|
||||
**Desktop 默认连接 Multica Cloud,但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# 编辑 apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
最小自部署配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws`,`https` 对应 `wss`,`http` 对应 `ws`)和 `appUrl`(API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
如果 `desktop.json` 存在但内容无效,Desktop 会 fail closed,显示阻塞式配置错误,而不是悄悄回退到 Cloud。开发构建里,`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
|
||||
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
@@ -21,7 +21,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | Dynamic discovery |
|
||||
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
|
||||
@@ -103,7 +103,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
| Kimi | `.kimi/skills/` | ✅ Native |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ Native |
|
||||
| OpenCode | `.opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
@@ -21,7 +21,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 动态发现 |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
|
||||
@@ -103,7 +103,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
| Kimi | `.kimi/skills/` | ✅ 原生 |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -116,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
|
||||
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path
|
||||
|
||||
@@ -115,4 +115,4 @@ multica setup self-host
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
|
||||
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
|
||||
@@ -372,7 +372,7 @@ skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- OpenCode → `.opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
|
||||
25
packages/core/runtimes/cli-version.test.ts
Normal file
25
packages/core/runtimes/cli-version.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkQuickCreateCliVersion } from "./cli-version";
|
||||
|
||||
describe("checkQuickCreateCliVersion", () => {
|
||||
it("returns ok for a tagged release at or above the minimum", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("0.3.1").state).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns too_old for a tagged release below the minimum", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.15").state).toBe("too_old");
|
||||
});
|
||||
|
||||
it("returns missing for empty or unparsable input", () => {
|
||||
expect(checkQuickCreateCliVersion("").state).toBe("missing");
|
||||
expect(checkQuickCreateCliVersion(undefined).state).toBe("missing");
|
||||
expect(checkQuickCreateCliVersion("not-a-version").state).toBe("missing");
|
||||
});
|
||||
|
||||
it("treats git-describe dev builds as ok regardless of base tag", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935-dirty").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,14 @@ export interface CliVersionCheck {
|
||||
|
||||
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
// Matches the `git describe --tags --always --dirty` output for a build past
|
||||
// the latest tag, e.g. `v0.2.15-235-gdaf0e935` or `v0.2.15-235-gdaf0e935-dirty`.
|
||||
// Daemons built from source (Makefile `make build` / `make daemon`) report this
|
||||
// shape; tagged releases are bare semver. Treating dev-described daemons as OK
|
||||
// is what keeps `pnpm dev:desktop` + `make daemon` unblocked without weakening
|
||||
// the gate for staging or production users running stale stable releases.
|
||||
const DEV_DESCRIBE_RE = /^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+/;
|
||||
|
||||
function parseSemver(raw: string): [number, number, number] | null {
|
||||
const m = SEMVER_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
@@ -40,9 +48,14 @@ function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
* Check a daemon-reported CLI version string against the minimum. Returns
|
||||
* `"missing"` for empty/unparsable input (fail closed — same policy as the
|
||||
* server) and `"too_old"` for a parsable version below the threshold.
|
||||
* Dev-built daemons (git-describe shape) are always OK — the version string
|
||||
* itself is the shared signal, so frontend and server agree by construction.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
if (DEV_DESCRIBE_RE.test(current)) {
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
|
||||
@@ -23,4 +23,6 @@ export interface TimelineEntry {
|
||||
comment_type?: string;
|
||||
reactions?: Reaction[];
|
||||
attachments?: Attachment[];
|
||||
/** Set by frontend coalescing when consecutive identical activities are merged. */
|
||||
coalesced_count?: number;
|
||||
}
|
||||
|
||||
@@ -113,10 +113,14 @@ function formatActivity(
|
||||
return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`;
|
||||
case "description_updated":
|
||||
return "updated the description";
|
||||
case "task_completed":
|
||||
return "completed the task";
|
||||
case "task_failed":
|
||||
return "task failed";
|
||||
case "task_completed": {
|
||||
const n = entry.coalesced_count ?? 1;
|
||||
return n > 1 ? `completed the task (${n} times)` : "completed the task";
|
||||
}
|
||||
case "task_failed": {
|
||||
const n = entry.coalesced_count ?? 1;
|
||||
return n > 1 ? `task failed (${n} times)` : "task failed";
|
||||
}
|
||||
default:
|
||||
return entry.action ?? "";
|
||||
}
|
||||
@@ -259,8 +263,11 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesce: same actor + same action within 2 min → keep last only
|
||||
// Coalesce consecutive activities from the same actor + action.
|
||||
// - task_completed / task_failed: no time limit (these repeat across runs)
|
||||
// - all other actions: within a 2-minute window
|
||||
const COALESCE_MS = 2 * 60 * 1000;
|
||||
const NO_TIME_LIMIT_ACTIONS = new Set(["task_completed", "task_failed"]);
|
||||
const coalesced: TimelineEntry[] = [];
|
||||
for (const entry of topLevel) {
|
||||
if (entry.type === "activity") {
|
||||
@@ -270,9 +277,10 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
prev.action === entry.action &&
|
||||
prev.actor_type === entry.actor_type &&
|
||||
prev.actor_id === entry.actor_id &&
|
||||
Math.abs(new Date(entry.created_at).getTime() - new Date(prev.created_at).getTime()) <= COALESCE_MS
|
||||
(NO_TIME_LIMIT_ACTIONS.has(entry.action!) ||
|
||||
Math.abs(new Date(entry.created_at).getTime() - new Date(prev.created_at).getTime()) <= COALESCE_MS)
|
||||
) {
|
||||
coalesced[coalesced.length - 1] = entry;
|
||||
coalesced[coalesced.length - 1] = { ...entry, coalesced_count: (prev.coalesced_count ?? 1) + 1 };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,10 @@ export function AgentCreatePanel({
|
||||
// daemons handle attachments and partial-failure retries incorrectly
|
||||
// (see PR #1851 / MUL-1496). Pre-check on the picker so the user gets
|
||||
// immediate feedback instead of waiting for the inbox failure; the
|
||||
// server re-validates as the trust boundary.
|
||||
// server re-validates as the trust boundary. Dev-built daemons
|
||||
// (git-describe shape) are exempted inside checkQuickCreateCliVersion
|
||||
// — frontend and server share the same signal there, so they agree by
|
||||
// construction across web/desktop/staging without comparing env flags.
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const selectedRuntime = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Trash2, UserMinus } from "lucide-react";
|
||||
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Plus, Trash2, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,6 +14,7 @@ import { pinListOptions } from "@multica/core/pins";
|
||||
import { useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
@@ -96,10 +97,12 @@ function PropRow({
|
||||
const projectViewStore = createIssueViewStore("project_issues_view");
|
||||
|
||||
function ProjectIssuesContent({
|
||||
projectId,
|
||||
projectIssues,
|
||||
scope,
|
||||
filter,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectIssues: Issue[];
|
||||
scope: string;
|
||||
filter: MyIssuesFilter;
|
||||
@@ -146,10 +149,21 @@ function ProjectIssuesContent({
|
||||
|
||||
if (projectIssues.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues linked</p>
|
||||
<p className="text-xs">Assign issues to this project from the issue detail page.</p>
|
||||
<p className="text-xs">Create a new issue or assign existing ones to this project.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("create-issue", { project_id: projectId })
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -224,12 +238,17 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
id: "multica_project_detail_layout",
|
||||
});
|
||||
const sidebarRef = usePanelRef();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
// Desktop and mobile sidebar state must be separate. A single state defaulting
|
||||
// to `true` made the mobile <Sheet> mount in the open position on first render
|
||||
// (after `useIsMobile()` flipped from false→true), briefly covering the page
|
||||
// with its modal backdrop and locking scroll — leaving the page unresponsive.
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const sidebarOpen = isMobile ? mobileSidebarOpen : desktopSidebarOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
sidebarRef.current?.collapse();
|
||||
setMobileSidebarOpen(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
@@ -560,7 +579,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
className={sidebarOpen ? "" : "text-muted-foreground"}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
setMobileSidebarOpen((open) => !open);
|
||||
} else {
|
||||
const panel = sidebarRef.current;
|
||||
if (!panel) return;
|
||||
@@ -581,6 +600,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
<ViewStoreProvider store={projectViewStore}>
|
||||
<IssuesHeader scopedIssues={projectIssues} />
|
||||
<ProjectIssuesContent
|
||||
projectId={projectId}
|
||||
projectIssues={projectIssues}
|
||||
scope={projectScope}
|
||||
filter={projectFilter}
|
||||
@@ -593,13 +613,13 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
{!isMobile && (
|
||||
<ResizablePanel
|
||||
id="sidebar"
|
||||
defaultSize={sidebarOpen ? 320 : 0}
|
||||
defaultSize={desktopSidebarOpen ? 320 : 0}
|
||||
minSize={260}
|
||||
maxSize={420}
|
||||
collapsible
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
panelRef={sidebarRef}
|
||||
onResize={(size) => setSidebarOpen(size.inPixels > 0)}
|
||||
onResize={(size) => setDesktopSidebarOpen(size.inPixels > 0)}
|
||||
>
|
||||
<div className="overflow-y-auto border-l h-full">
|
||||
<div className="p-4">
|
||||
@@ -609,7 +629,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
</ResizablePanel>
|
||||
)}
|
||||
{isMobile && (
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
||||
<SheetContent side="right" showCloseButton={false} className="w-[320px] overflow-y-auto p-4">
|
||||
{sidebarContent}
|
||||
</SheetContent>
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
// Project Resources sidebar section.
|
||||
//
|
||||
@@ -113,19 +118,31 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-1">
|
||||
{workspace.repos.map((repo) => {
|
||||
const isAttached = attachedUrls.has(repo.url);
|
||||
const isDisabled = isAttached || createResource.isPending;
|
||||
return (
|
||||
// Use aria-disabled instead of the native `disabled` attribute so
|
||||
// hover events still reach the tooltip trigger on attached rows
|
||||
// (browsers suppress pointer events on disabled form controls).
|
||||
<button
|
||||
key={repo.url}
|
||||
type="button"
|
||||
disabled={isAttached || createResource.isPending}
|
||||
aria-disabled={isDisabled}
|
||||
onClick={async () => {
|
||||
if (isDisabled) return;
|
||||
await handleAttach(repo.url);
|
||||
setAddOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors aria-disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:hover:bg-transparent"
|
||||
>
|
||||
<FolderGit className="size-3.5" />
|
||||
<span className="truncate flex-1">{repo.url}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="truncate flex-1">{repo.url}</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{repo.url}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isAttached && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
attached
|
||||
@@ -162,14 +179,21 @@ function ResourceRow({
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs group">
|
||||
<FolderGit className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<a
|
||||
href={ref.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate flex-1 hover:underline"
|
||||
>
|
||||
{resource.label || ref.url}
|
||||
</a>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href={ref.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate flex-1 hover:underline"
|
||||
>
|
||||
{resource.label || ref.url}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{ref.url}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
|
||||
@@ -119,7 +119,9 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
|
||||
const isRuntimeOwner = user && runtime.owner_id === user.id;
|
||||
const canDelete = isAdmin || isRuntimeOwner;
|
||||
|
||||
const servingAgents = agents.filter((a) => a.runtime_id === runtime.id);
|
||||
const servingAgents = agents.filter(
|
||||
(a) => a.runtime_id === runtime.id && !a.archived_at,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteMutation.mutate(runtime.id, {
|
||||
|
||||
@@ -160,6 +160,21 @@ function Get-WindowsCliArch {
|
||||
Write-Fail "Unsupported Windows architecture ($details). Only x64 and ARM64 are supported."
|
||||
}
|
||||
|
||||
function Get-InstalledCliVersion {
|
||||
try {
|
||||
$firstLine = multica version 2>$null | Select-Object -First 1
|
||||
if ("$firstLine" -match '\b(v?\d+(?:\.\d+)+)\b') {
|
||||
$version = $Matches[1]
|
||||
if ($version -notlike 'v*') {
|
||||
$version = "v$version"
|
||||
}
|
||||
return $version
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI Installation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -196,9 +211,21 @@ function Install-CliBinary {
|
||||
$checksumUrl = "https://github.com/multica-ai/multica/releases/download/$latest/checksums.txt"
|
||||
try {
|
||||
$checksums = Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing -ErrorAction Stop
|
||||
$checksumContent = if ($checksums.Content -is [byte[]]) {
|
||||
[System.Text.Encoding]::UTF8.GetString($checksums.Content)
|
||||
} else {
|
||||
[string]$checksums.Content
|
||||
}
|
||||
$zipFile = Join-Path $tmpDir "multica.zip"
|
||||
$actualHash = (Get-FileHash -Path $zipFile -Algorithm SHA256).Hash.ToLower()
|
||||
$expectedLine = ($checksums.Content -split "`n") | Where-Object { $_ -match "multica-cli-$version-windows-$arch\.zip" } | Select-Object -First 1
|
||||
$releaseAsset = "multica-cli-$version-windows-$arch.zip"
|
||||
$legacyAsset = "multica_windows_$arch.zip"
|
||||
$expectedLine = ($checksumContent -split "`r?`n") |
|
||||
Where-Object {
|
||||
$_ -match [regex]::Escape($releaseAsset) -or
|
||||
$_ -match [regex]::Escape($legacyAsset)
|
||||
} |
|
||||
Select-Object -First 1
|
||||
if ($expectedLine) {
|
||||
$expectedHash = ($expectedLine -split "\s+")[0].ToLower()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
@@ -207,7 +234,7 @@ function Install-CliBinary {
|
||||
}
|
||||
Write-Ok "Checksum verified"
|
||||
} else {
|
||||
Write-Warn "Could not find checksum entry for windows_$arch — skipping verification."
|
||||
Write-Warn "Could not find checksum entry for $releaseAsset — skipping verification."
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "Could not download checksums.txt — skipping verification."
|
||||
@@ -253,18 +280,18 @@ function Add-ToUserPath {
|
||||
|
||||
function Install-Cli {
|
||||
if (Test-CommandExists "multica") {
|
||||
$currentVer = (multica version 2>$null) -replace '.*?(v[\d.]+).*','$1'
|
||||
$currentVer = Get-InstalledCliVersion
|
||||
$latestVer = Get-LatestVersion
|
||||
|
||||
$currentCmp = $currentVer -replace '^v',''
|
||||
$currentCmp = if ($currentVer) { $currentVer -replace '^v','' } else { $null }
|
||||
$latestCmp = if ($latestVer) { $latestVer -replace '^v','' } else { $null }
|
||||
|
||||
$isUpToDate = -not $latestCmp
|
||||
$isUpToDate = $currentCmp -and -not $latestCmp
|
||||
if (-not $isUpToDate) {
|
||||
try {
|
||||
$isUpToDate = [System.Version]$currentCmp -ge [System.Version]$latestCmp
|
||||
$isUpToDate = $currentCmp -and $latestCmp -and ([System.Version]$currentCmp -ge [System.Version]$latestCmp)
|
||||
} catch {
|
||||
$isUpToDate = $currentCmp -eq $latestCmp
|
||||
$isUpToDate = $currentCmp -and $latestCmp -and ($currentCmp -eq $latestCmp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +303,7 @@ function Install-Cli {
|
||||
Write-Info "Multica CLI $currentVer installed, latest is $latestVer - upgrading..."
|
||||
Install-CliBinary
|
||||
|
||||
$newVer = (multica version 2>$null) -replace '.*?(v[\d.]+).*','$1'
|
||||
$newVer = Get-InstalledCliVersion
|
||||
Write-Ok "Multica CLI upgraded ($currentVer -> $newVer)"
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
||||
// Codex: skills → handled separately in Prepare via codex-home
|
||||
// Copilot: skills → {workDir}/.github/skills/{name}/SKILL.md (native project-level discovery)
|
||||
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
||||
// OpenCode: skills → {workDir}/.opencode/skills/{name}/SKILL.md (native discovery)
|
||||
// Pi: skills → {workDir}/.pi/skills/{name}/SKILL.md (native discovery)
|
||||
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
||||
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
|
||||
@@ -131,8 +131,8 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
|
||||
skillsDir = filepath.Join(workDir, ".github", "skills")
|
||||
case "opencode":
|
||||
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
|
||||
// OpenCode natively discovers skills from .opencode/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".opencode", "skills")
|
||||
case "pi":
|
||||
// Pi natively discovers skills from .pi/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".pi", "skills")
|
||||
|
||||
@@ -740,17 +740,17 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
// Skills should be in .config/opencode/skills/ (native discovery).
|
||||
skillMd, err := os.ReadFile(filepath.Join(dir, ".config", "opencode", "skills", "go-conventions", "SKILL.md"))
|
||||
// Skills should be in .opencode/skills/ (native discovery).
|
||||
skillMd, err := os.ReadFile(filepath.Join(dir, ".opencode", "skills", "go-conventions", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .config/opencode/skills/go-conventions/SKILL.md: %v", err)
|
||||
t.Fatalf("failed to read .opencode/skills/go-conventions/SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
|
||||
t.Error("SKILL.md missing content")
|
||||
}
|
||||
|
||||
// Supporting files should also be under .config/opencode/skills/.
|
||||
supportFile, err := os.ReadFile(filepath.Join(dir, ".config", "opencode", "skills", "go-conventions", "templates", "example.go"))
|
||||
// Supporting files should also be under .opencode/skills/.
|
||||
supportFile, err := os.ReadFile(filepath.Join(dir, ".opencode", "skills", "go-conventions", "templates", "example.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read supporting file: %v", err)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func formatProjectResource(r ProjectResourceForEnv) string {
|
||||
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// For Copilot: writes {workDir}/AGENTS.md (skills discovered natively from .github/skills/)
|
||||
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
|
||||
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .opencode/skills/)
|
||||
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
|
||||
// For Hermes: writes {workDir}/AGENTS.md (skills fall back to .agent_context/skills/; AGENTS.md points there)
|
||||
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
|
||||
|
||||
@@ -50,6 +50,8 @@ func gitEnv() []string {
|
||||
)
|
||||
}
|
||||
|
||||
var agentGitExcludePatterns = []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude", ".opencode"}
|
||||
|
||||
// RepoInfo describes a repository to cache.
|
||||
type RepoInfo struct {
|
||||
URL string
|
||||
@@ -435,7 +437,7 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
return nil, fmt.Errorf("update existing worktree: %w", err)
|
||||
}
|
||||
|
||||
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude", ".config/opencode"} {
|
||||
for _, pattern := range agentGitExcludePatterns {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
@@ -475,7 +477,7 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
}
|
||||
|
||||
// Exclude agent context files from git tracking.
|
||||
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude", ".config/opencode"} {
|
||||
for _, pattern := range agentGitExcludePatterns {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
|
||||
@@ -502,6 +502,55 @@ func TestCreateWorktree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktreeExcludesOpenCodeSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "OpenCode",
|
||||
TaskID: "opencode-exclude-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree failed: %v", err)
|
||||
}
|
||||
|
||||
exclude := gitInfoExclude(t, result.Path)
|
||||
if !strings.Contains(exclude, ".opencode\n") {
|
||||
t.Fatalf("expected .git/info/exclude to contain .opencode, got:\n%s", exclude)
|
||||
}
|
||||
if strings.Contains(exclude, ".config/opencode") {
|
||||
t.Fatalf("expected .git/info/exclude to not contain stale .config/opencode, got:\n%s", exclude)
|
||||
}
|
||||
}
|
||||
|
||||
func gitInfoExclude(t *testing.T, worktreePath string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git rev-parse --git-dir failed in %s: %v", worktreePath, err)
|
||||
}
|
||||
gitDir := strings.TrimSpace(string(out))
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(worktreePath, gitDir)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(gitDir, "info", "exclude"))
|
||||
if err != nil {
|
||||
t.Fatalf("read .git/info/exclude failed: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestCreateWorktreeNotCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -13,8 +14,6 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
@@ -937,7 +936,9 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
// handling, no-retry on partial failure). Older daemons either
|
||||
// double-create issues on partial CLI failures or mishandle pasted
|
||||
// screenshot URLs; fail closed before enqueuing rather than surface
|
||||
// the breakage as an inbox failure twenty seconds later.
|
||||
// the breakage as an inbox failure twenty seconds later. Dev-built
|
||||
// daemons (git-describe shape) are exempted inside CheckMinCLIVersion
|
||||
// so `make daemon` works without weakening staging or production.
|
||||
if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), agent.RuntimeID); status != 0 {
|
||||
writeJSON(w, status, payload)
|
||||
return
|
||||
|
||||
@@ -32,15 +32,30 @@ var (
|
||||
ErrCLIVersionTooOld = errors.New("multica CLI version is below required minimum")
|
||||
)
|
||||
|
||||
// devDescribeRe matches the `git describe --tags --always --dirty` output for
|
||||
// a build past the latest tag, e.g. `v0.2.15-235-gdaf0e935` (optionally with a
|
||||
// trailing `-dirty`). Daemons built from source (Makefile `make build` / `make
|
||||
// daemon`) report this shape; tagged releases are bare semver. Treating dev-
|
||||
// described daemons as OK keeps `make daemon` unblocked without weakening the
|
||||
// gate for staging or production users running stale stable releases.
|
||||
var devDescribeRe = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+`)
|
||||
|
||||
// CheckMinCLIVersion returns nil when `detected` parses as ≥ minimum. Returns
|
||||
// ErrCLIVersionMissing for empty or unparsable input, and ErrCLIVersionTooOld
|
||||
// when parsable but below the minimum. The caller can check for these
|
||||
// sentinel errors with errors.Is to drive the response shape.
|
||||
//
|
||||
// Dev-built daemons (git-describe shape) always pass — the version string
|
||||
// itself is the shared signal, so the modal pre-check and this server gate
|
||||
// agree by construction without needing to compare separate env flags.
|
||||
func CheckMinCLIVersion(detected string) error {
|
||||
d := strings.TrimSpace(detected)
|
||||
if d == "" {
|
||||
return ErrCLIVersionMissing
|
||||
}
|
||||
if devDescribeRe.MatchString(d) {
|
||||
return nil
|
||||
}
|
||||
parsed, err := parseSemver(d)
|
||||
if err != nil {
|
||||
return ErrCLIVersionMissing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,32 @@ func TestSemverLessThan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMinCLIVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr error
|
||||
}{
|
||||
{"tagged release at minimum", "v0.2.20", nil},
|
||||
{"tagged release above minimum", "0.3.1", nil},
|
||||
{"tagged release below minimum", "v0.2.15", ErrCLIVersionTooOld},
|
||||
{"empty string", "", ErrCLIVersionMissing},
|
||||
{"unparsable", "not-a-version", ErrCLIVersionMissing},
|
||||
{"git-describe dev build past old tag", "v0.2.15-235-gdaf0e935", nil},
|
||||
{"git-describe dirty dev build", "v0.2.15-235-gdaf0e935-dirty", nil},
|
||||
{"git-describe dev build past current tag", "v0.2.20-3-gabc1234", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := CheckMinCLIVersion(tt.input)
|
||||
if tt.wantErr == nil && err != nil {
|
||||
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want nil", tt.name, tt.input, err)
|
||||
}
|
||||
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
|
||||
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want %v", tt.name, tt.input, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMinVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
agentType string
|
||||
|
||||
Reference in New Issue
Block a user