fix(desktop): make packaged app usable for fresh accounts (#1074)

* feat(desktop): add macOS app icon

Replace the default electron-vite scaffold icon with the Multica asterisk
icon. Adds build/icon.icns so electron-builder picks it up automatically
via the `buildResources: build` config — no YAML change needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(desktop): run electron-vite build inside package script

The package wrapper only ran bundle-cli.mjs and electron-builder, so
electron-builder silently packaged whatever was already in out/. On a
fresh checkout (or after a partial build) this shipped an app with a
missing renderer bundle, which white-screens on launch.

Add an explicit `electron-vite build` step between bundle-cli and
electron-builder so `pnpm package` is self-contained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(desktop): restore shell PATH in main process for GUI launches

macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
~/.zshrc, Homebrew, nvm, ~/.local/bin, and other shell config. Child
processes spawned from the main process — including the bundled multica
CLI used by daemon-manager — inherit the same stripped PATH, so the CLI
fails to locate agent binaries like claude, codex, opencode, etc. with
"no agent CLI found: … ensure it is on PATH".

Use `fix-path` to recover the real shell PATH at startup, then prepend
common install locations (/opt/homebrew/bin, /usr/local/bin,
~/.local/bin) as a fallback for broken shell rc or non-interactive
$SHELL. Runs before setupDaemonManager so every subsequent spawn sees
the corrected PATH.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(desktop): show onboarding wizard when authed user has no workspace

Desktop is a single-shell architecture — every route, including
/onboarding, lives inside DashboardGuard. The guard returns its loading
fallback whenever workspace is null, so a fresh account that logs in
with no workspaces ends up stuck on the spinner forever: the
`replace(onboardingPath)` redirect navigates the tab router, but
DashboardGuard still blocks its children because workspace is still
null.

Handle the empty-workspace case in DesktopShell itself: render
OnboardingWizard as a full-screen takeover, bypassing DashboardGuard.
A ref-based flag freezes the "needs onboarding" decision at first
mount so creating a workspace mid-wizard (step 0) doesn't unmount the
wizard and dump the user into the main shell before steps 1-3
(runtime, agent, get started) finish.

Also add a local `bootstrapping` flag in AppContent so DesktopShell
doesn't mount until the deep-link login chain (loginWithToken →
syncToken → listWorkspaces → hydrateWorkspace) fully resolves. Without
it, the shell would briefly see `!workspace` before hydration lands,
causing users with existing workspaces to flash the wizard (or, with
the ref freeze, get stuck in it permanently).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(desktop): extract OnboardingGate with test coverage

Pull the "render onboarding wizard when authed user has no workspace"
logic out of DesktopShell into a dedicated OnboardingGate component.
Replaces the ref-based freeze with a lazy useState initializer
(`useState(() => !hasWorkspace)`), which is React's idiomatic pattern
for "capture a value once at mount". The freeze semantics are unchanged:
creating a workspace in step 0 of the wizard must not unmount it,
because steps 1-3 still need to run; only `onComplete` flips the gate
back to the main shell.

Also de-duplicates the wrapping DesktopNavigationProvider — both branches
of the shell now share a single provider instead of re-mounting one per
branch.

Wire up jsdom + @testing-library/react in the desktop vitest config
(mirroring packages/views) and add three deterministic tests covering:
  1. children render when hasWorkspace is true at mount
  2. wizard stays mounted when hasWorkspace flips to true mid-flow
  3. onComplete transitions the gate to children

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(desktop): drop redundant syncToken call in deep-link login

daemonAPI.syncToken was called twice on a deep-link login: once inside
the deep-link handler's bootstrapping chain, and again in the
useEffect([user]) that reacts to the user state change. Both calls spawn
a multica CLI subprocess over IPC, wasting ~1-2s of startup time on the
critical login path.

Keep the [user] effect (it covers the session-restore path too) and
drop the explicit call from the deep-link handler. Net effect: login
latency shrinks, behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen
2026-04-15 17:27:43 +08:00
committed by GitHub
parent 621526b38d
commit 0e8a7b1734
13 changed files with 283 additions and 40 deletions

Binary file not shown.

View File

@@ -29,6 +29,7 @@
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
@@ -38,6 +39,8 @@
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
@@ -45,6 +48,7 @@
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 735 KiB

View File

@@ -5,13 +5,21 @@
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
// and copied into resources/bin/), then invokes electron-builder with
// `-c.extraMetadata.version=<derived>` so the override applies at build
// time without mutating the tracked package.json.
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
// into resources/bin/), then `electron-vite build` to produce the
// main/preload/renderer bundles under out/, then invokes electron-builder
// with `-c.extraMetadata.version=<derived>` so the override applies at
// build time without mutating the tracked package.json.
//
// The electron-vite step is important: electron-builder only packages
// whatever is already in out/, so skipping it (or relying on stale
// artifacts from a prior partial build) ships an app with missing
// renderer code and white-screens on launch.
//
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
// unchanged (e.g. `--mac --arm64`).
// unchanged (e.g. `--mac --arm64`). For an unsigned local smoke-test
// build, set `CSC_IDENTITY_AUTO_DISCOVERY=false` so electron-builder falls
// back to an ad-hoc signature instead of requiring a Developer ID cert.
//
// The `normalizeGitVersion` helper is exported so tests can cover the
// version-derivation logic without shelling out.
@@ -64,7 +72,26 @@ function main() {
cwd: desktopRoot,
});
// Step 2: derive the version that should be written into the app.
// Step 2: build the Electron main/preload/renderer bundles. Without
// this step electron-builder silently packages whatever is already in
// out/, which on a fresh checkout (or after a partial build) ships an
// app that white-screens because the renderer bundle is missing.
const viteResult = spawnSync("electron-vite", ["build"], {
stdio: "inherit",
cwd: desktopRoot,
});
if (viteResult.error) {
console.error(
"[package] failed to spawn electron-vite:",
viteResult.error.message,
);
process.exit(1);
}
if (viteResult.status !== 0) {
process.exit(viteResult.status ?? 1);
}
// Step 3: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -74,12 +101,12 @@ function main() {
);
}
// Step 3: assemble electron-builder args.
// Step 4: assemble electron-builder args.
const passthrough = process.argv.slice(2);
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
@@ -95,7 +122,7 @@ function main() {
builderArgs.push(...passthrough);
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {

View File

@@ -1,9 +1,31 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
// Run the user's login shell once to recover the real PATH so the bundled
// multica CLI can find agent binaries like claude/codex/opencode. Must run
// before any child_process.spawn / execFile call in the main process —
// ES module imports are hoisted, so this block executes before createWindow
// or any daemon-manager spawn.
if (process.platform !== "win32") {
fixPath();
// Fallback: prepend common install locations in case fix-path came up
// short (broken shell rc, non-interactive $SHELL, missing entries). Safe
// to duplicate — PATH lookups short-circuit on first match.
const fallbackPaths = [
"/opt/homebrew/bin",
"/usr/local/bin",
join(homedir(), ".local/bin"),
];
process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`;
}
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
@@ -13,6 +13,14 @@ import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
// hydrateWorkspace sequentially. loginWithToken sets user+isLoading=false
// as soon as getMe resolves, which would cause DesktopShell to mount
// before the workspace list is hydrated and briefly see `!workspace`.
// This local flag keeps the loading screen up until the whole chain
// finishes, so the shell's "needs onboarding?" check gets a definitive
// workspace state on first render.
const [bootstrapping, setBootstrapping] = useState(false);
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
@@ -20,17 +28,21 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
// daemonAPI.syncToken is handled separately by the [user] effect below, which
// fires whenever a user logs in (deep link, session restore, account switch).
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
setBootstrapping(true);
try {
const loggedIn = await useAuthStore.getState().loginWithToken(token);
await window.daemonAPI.syncToken(token, loggedIn.id);
await useAuthStore.getState().loginWithToken(token);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
} catch {
// Token invalid or expired — user stays on login page
} finally {
setBootstrapping(false);
}
});
}, []);
@@ -51,7 +63,7 @@ function AppContent() {
})();
}, [user]);
if (isLoading) {
if (isLoading || bootstrapping) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />

View File

@@ -12,8 +12,11 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { OnboardingWizard } from "@multica/views/onboarding";
import { useWorkspaceStore } from "@multica/core/workspace";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { OnboardingGate } from "./onboarding-gate";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -86,34 +89,45 @@ export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
const workspace = useWorkspaceStore((s) => s.workspace);
return (
<DesktopNavigationProvider>
<DashboardGuard
loginPath="/login"
loadingFallback={
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
<OnboardingGate
hasWorkspace={!!workspace}
onboarding={(onComplete) => (
<div className="h-screen overflow-auto">
<OnboardingWizard onComplete={onComplete} />
</div>
}
)}
>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
</div>
<DashboardGuard
loginPath="/login"
loadingFallback={
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
</DashboardGuard>
}
>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
</DashboardGuard>
</OnboardingGate>
</DesktopNavigationProvider>
);
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { useState } from "react";
import { OnboardingGate } from "./onboarding-gate";
describe("OnboardingGate", () => {
it("renders children when a workspace exists at mount", () => {
render(
<OnboardingGate
hasWorkspace={true}
onboarding={() => <div data-testid="wizard">wizard</div>}
>
<div data-testid="main">main shell</div>
</OnboardingGate>,
);
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByTestId("wizard")).not.toBeInTheDocument();
});
it("keeps the wizard mounted even after hasWorkspace flips to true mid-flow", () => {
// Controls the hasWorkspace prop from outside so we can simulate
// step 0 of the wizard creating a workspace while steps 1-3 still need
// to render. The gate should ignore the prop change and hold the wizard.
function Harness() {
const [hasWorkspace, setHasWorkspace] = useState(false);
return (
<>
<button
type="button"
data-testid="grant-workspace"
onClick={() => setHasWorkspace(true)}
>
grant
</button>
<OnboardingGate
hasWorkspace={hasWorkspace}
onboarding={() => <div data-testid="wizard">wizard</div>}
>
<div data-testid="main">main shell</div>
</OnboardingGate>
</>
);
}
render(<Harness />);
expect(screen.getByTestId("wizard")).toBeInTheDocument();
act(() => {
screen.getByTestId("grant-workspace").click();
});
// Prop change alone does not dismiss the wizard — only onComplete does.
expect(screen.getByTestId("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("transitions to children after the wizard calls onComplete", () => {
render(
<OnboardingGate
hasWorkspace={false}
onboarding={(onComplete) => (
<button type="button" data-testid="finish" onClick={onComplete}>
finish
</button>
)}
>
<div data-testid="main">main shell</div>
</OnboardingGate>,
);
expect(screen.getByTestId("finish")).toBeInTheDocument();
act(() => {
screen.getByTestId("finish").click();
});
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByTestId("finish")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,33 @@
import { useState, type ReactNode } from "react";
/**
* Renders `onboarding` as a full-screen takeover when the user logs in
* without a workspace, otherwise renders `children`.
*
* The "needs onboarding" decision is frozen at first mount via the lazy
* useState initializer — creating a workspace mid-wizard (step 0) must not
* unmount the wizard, because steps 1-3 (runtime, agent, get started) still
* need to run. Calling the `onComplete` passed to `onboarding` flips the
* gate to `children`.
*
* Assumes `hasWorkspace` is definitive at first render: desktop only mounts
* DesktopShell after AppContent's bootstrapping flag resolves, so the first
* render of this component reflects the actual server state.
*/
export function OnboardingGate({
hasWorkspace,
onboarding,
children,
}: {
hasWorkspace: boolean;
onboarding: (onComplete: () => void) => ReactNode;
children: ReactNode;
}) {
const [initialNeedsOnboarding] = useState(() => !hasWorkspace);
const [onboardingDone, setOnboardingDone] = useState(false);
if (initialNeedsOnboarding && !onboardingDone) {
return <>{onboarding(() => setOnboardingDone(true))}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -4,7 +4,8 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
"src/preload/*.d.ts",
"test/setup.ts"
],
"compilerOptions": {
"composite": true,

View File

@@ -1,10 +1,13 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
environment: "node",
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
passWithNoTests: true,
},
});

45
pnpm-lock.yaml generated
View File

@@ -126,6 +126,9 @@ importers:
electron-updater:
specifier: ^6.8.3
version: 6.8.3
fix-path:
specifier: ^5.0.0
version: 5.0.0
react-router-dom:
specifier: ^7.6.0
version: 7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -148,6 +151,12 @@ importers:
'@tailwindcss/vite':
specifier: ^4
version: 4.2.2(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
'@testing-library/jest-dom':
specifier: 'catalog:'
version: 6.9.1
'@testing-library/react':
specifier: 'catalog:'
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@types/node':
specifier: 'catalog:'
version: 25.5.0
@@ -169,6 +178,9 @@ importers:
electron-vite:
specifier: ^5.0.0
version: 5.0.0(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
jsdom:
specifier: 'catalog:'
version: 29.0.1(@noble/hashes@1.8.0)
react:
specifier: 'catalog:'
version: 19.2.3
@@ -3533,6 +3545,10 @@ packages:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
default-shell@2.2.0:
resolution: {integrity: sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
@@ -4000,6 +4016,10 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fix-path@5.0.0:
resolution: {integrity: sha512-erEWGGCN7RIu1bXTCfNVpVBdm0f5mwcbeja+4QXiEZzIQukP401sbpu8gd3Ny1vS34YNeswyMO0TdT2tP5OlHA==}
engines: {node: '>=20'}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@@ -6113,6 +6133,14 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-env@4.0.3:
resolution: {integrity: sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
shell-path@3.1.0:
resolution: {integrity: sha512-s/9q9PEtcRmDTz69+cJ3yYBAe9yGrL7e46gm2bU4pQ9N48ecPK9QrGFnLwYgb4smOHskx4PL7wCNMktW2AoD+g==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
@@ -9673,6 +9701,8 @@ snapshots:
bundle-name: 4.1.0
default-browser-id: 5.0.1
default-shell@2.2.0: {}
defaults@1.0.4:
dependencies:
clone: 1.0.4
@@ -10376,6 +10406,11 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
fix-path@5.0.0:
dependencies:
shell-path: 3.1.0
strip-ansi: 7.2.0
flat-cache@4.0.1:
dependencies:
flatted: 3.4.2
@@ -13081,6 +13116,16 @@ snapshots:
shebang-regex@3.0.0: {}
shell-env@4.0.3:
dependencies:
default-shell: 2.2.0
execa: 5.1.1
strip-ansi: 7.2.0
shell-path@3.1.0:
dependencies:
shell-env: 4.0.3
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0