From c2d575e66c184cd57aa5528cc18c7a6091d0b504 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Mon, 28 Sep 2026 15:37:03 +0800 Subject: [PATCH 01/16] feat(dashboard): add project creation and template catalog Allow creating projects from built-in templates in a workspace and add a searchable template catalog with Chinese and English descriptions. Replace the global sidebar with brand navigation, move language and theme controls into the header, and add appearance preferences to settings. Start the dashboard server before opening the browser to avoid blocking startup on Linux. Validation: task check passes, including all 83 dashboard tests; desktop, narrow-screen, language switching, and theme behavior verified in browser. --- apps/dashboard/src/App.tsx | 2 - apps/dashboard/src/api/project-creation.ts | 30 +++ .../dashboard/src/components/AppMenu.test.tsx | 149 +++++++++++ apps/dashboard/src/components/AppMenu.tsx | 96 +++++++ apps/dashboard/src/components/AppSidebar.tsx | 140 ----------- .../src/components/LanguageSwitcher.tsx | 45 ++-- apps/dashboard/src/components/TopBar.tsx | 30 ++- .../src/components/ui/dropdown-menu.tsx | 11 +- .../AccountSettings.test.tsx | 40 ++- .../infisical-session/AccountSettings.tsx | 39 +++ .../CreateProjectDialog.test.tsx | 182 ++++++++++++++ .../project-creation/CreateProjectDialog.tsx | 238 ++++++++++++++++++ .../project-settings/ProjectInspector.tsx | 49 +++- .../workspace-registry/WorkspaceMenu.tsx | 93 +++++++ .../workspace-registry/WorkspaceRail.tsx | 226 ----------------- apps/dashboard/src/locales/en-US.json | 163 +++++++++++- apps/dashboard/src/locales/zh-CN.json | 163 +++++++++++- apps/dashboard/src/pages/Overview.test.tsx | 47 ++++ .../src/pages/ProjectTemplates.test.tsx | 153 +++++++++++ apps/dashboard/src/pages/ProjectTemplates.tsx | 238 ++++++++++++++++++ apps/dashboard/src/router/routes.test.tsx | 4 +- apps/dashboard/src/router/routes.tsx | 2 + packages/cli/internal/bootstrap/cli/root.go | 2 +- .../cli/internal/modules/creation/project.go | 3 +- .../internal/platform/i18n/locales/en-US.json | 3 +- .../internal/platform/i18n/locales/zh-CN.json | 3 +- .../cli/internal/transport/cobra/serve/cmd.go | 13 +- .../transport/http/handlers_project_create.go | 109 ++++++++ .../http/handlers_project_create_test.go | 163 ++++++++++++ .../transport/http/handlers_workspaces.go | 2 + .../cli/internal/transport/http/middleware.go | 11 + .../cli/internal/transport/http/responses.go | 6 + .../cli/internal/transport/http/server.go | 3 + .../cli/tests/e2e/serve_browser_linux_test.go | 100 ++++++++ 34 files changed, 2147 insertions(+), 411 deletions(-) create mode 100644 apps/dashboard/src/api/project-creation.ts create mode 100644 apps/dashboard/src/components/AppMenu.test.tsx create mode 100644 apps/dashboard/src/components/AppMenu.tsx delete mode 100644 apps/dashboard/src/components/AppSidebar.tsx create mode 100644 apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx create mode 100644 apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx create mode 100644 apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx delete mode 100644 apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx create mode 100644 apps/dashboard/src/pages/ProjectTemplates.test.tsx create mode 100644 apps/dashboard/src/pages/ProjectTemplates.tsx create mode 100644 packages/cli/internal/transport/http/handlers_project_create.go create mode 100644 packages/cli/internal/transport/http/handlers_project_create_test.go create mode 100644 packages/cli/tests/e2e/serve_browser_linux_test.go diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index e6b00a3..94e46b9 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,6 +1,5 @@ import type React from "react"; import { useMatch } from "react-router-dom"; -import { AppSidebar } from "@/components/AppSidebar"; import { TopBar } from "@/components/TopBar"; import { AppRoutes } from "@/router/routes"; import { cn } from "@/lib/utils"; @@ -10,7 +9,6 @@ export const App: React.FC = () => { return (
-
{ + return http.get(projectTemplatesKey); +} + +export async function createProject( + entryId: string, + input: { name: string; templateId: string }, +): Promise { + return http.post(`${workspaceBasePath(entryId)}/projects`, input, { timeout: 60000 }); +} diff --git a/apps/dashboard/src/components/AppMenu.test.tsx b/apps/dashboard/src/components/AppMenu.test.tsx new file mode 100644 index 0000000..293b2b5 --- /dev/null +++ b/apps/dashboard/src/components/AppMenu.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { SWRConfig } from "swr"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { AppMenu } from "@/components/AppMenu"; +import { TopBar } from "@/components/TopBar"; +import { ThemeProvider } from "@/providers/ThemeProvider"; +import i18n from "@/lib/i18n"; +import { useLocaleStore } from "@/lib/stores/locale"; +import { useThemeStore } from "@/lib/stores/theme"; + +const workspaces = [ + { entryId: "alpha", name: "Alpha", root: "/workspaces/alpha", status: "ready", projectCount: 1 }, + { + entryId: "beta", + name: "Beta", + root: "/workspaces/beta", + status: "identity-conflict", + projectCount: 2, + }, +]; +const server = setupServer( + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces })), + http.get("http://localhost/api/session", () => + HttpResponse.json({ session: { loggedIn: true, email: "test@example.com" } }), + ), +); +function LocationProbe() { + const location = useLocation(); + return ( + + {location.pathname} + {location.search} + + ); +} +function show(withHeader = false) { + return render( + new Map(), shouldRetryOnError: false }}> + + {withHeader ? : } + + + , + ); +} +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(async () => { + await i18n.changeLanguage("en-US"); + useThemeStore.setState({ mode: "light" }); + useLocaleStore.setState({ mode: "auto", resolved: "en-US" }); +}); +afterEach(() => { + server.resetHandlers(); + localStorage.removeItem("app_theme_mode"); + localStorage.removeItem("app_locale_mode"); + document.documentElement.classList.remove("light", "dark"); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.style.colorScheme = ""; +}); +afterAll(() => server.close()); + +describe("brand navigation menu", () => { + it.each(["en-US", "zh-CN"])( + "opens from the brand and switches workspace with the environment preserved in %s", + async (locale) => { + await i18n.changeLanguage(locale); + const user = userEvent.setup(); + show(); + const trigger = screen.getByRole("button", { name: i18n.t("appMenu.label") }); + expect(screen.queryByRole("menu")).toBeNull(); + await user.click(trigger); + const menu = await screen.findByRole("menu", { name: i18n.t("appMenu.label") }); + const selected = await within(menu).findByRole("menuitem", { name: /Alpha/ }); + expect(selected.getAttribute("aria-current")).toBe("page"); + expect(within(menu).getByRole("menuitem", { name: /Beta/ }).getAttribute("href")).toBe( + "/workspace/beta?env=preview", + ); + expect(await within(menu).findByText("test@example.com")).toBeDefined(); + await user.click(within(menu).getByRole("menuitem", { name: /Beta/ })); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(screen.getByTestId("location").textContent).toBe("/workspace/beta?env=preview"); + }, + ); + + it("opens with the keyboard, dismisses with Escape and returns focus to the brand", async () => { + const user = userEvent.setup(); + show(); + const trigger = screen.getByRole("button", { name: "One CLI navigation menu" }); + trigger.focus(); + await user.keyboard("{ArrowDown}"); + await screen.findByRole("menu"); + await user.keyboard("{Home}"); + expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "Home" })); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(document.activeElement).toBe(trigger); + }); + + it.each(["en-US", "zh-CN"])( + "switches theme and language from the header in %s", + async (locale) => { + await i18n.changeLanguage(locale); + const user = userEvent.setup(); + show(true); + const header = within(screen.getByRole("banner")); + await user.click(header.getByRole("button", { name: i18n.t("sidebar.themeToDark") })); + expect(useThemeStore.getState().mode).toBe("dark"); + expect(localStorage.getItem("app_theme_mode")).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + await user.click(header.getByRole("button", { name: i18n.t("sidebar.themeToLight") })); + expect(useThemeStore.getState().mode).toBe("light"); + const language = header.getByRole("button", { name: i18n.t("sidebar.language") }); + await user.click(language); + await user.click(await screen.findByRole("menuitemradio", { name: "中文" })); + expect(useLocaleStore.getState().mode).toBe("zh-CN"); + expect(localStorage.getItem("app_locale_mode")).toBe("zh-CN"); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(document.activeElement).toBe(language); + await user.click(header.getByRole("button", { name: i18n.t("appMenu.label") })); + const menu = await screen.findByRole("menu"); + expect(within(menu).queryByRole("menuitem", { name: i18n.t("sidebar.language") })).toBeNull(); + expect( + within(menu).queryByRole("menuitem", { name: i18n.t("sidebar.themeToDark") }), + ).toBeNull(); + }, + ); + + it("shows a retry action when the workspace registry fails", async () => { + server.use( + http.get("http://localhost/api/workspaces", () => new HttpResponse(null, { status: 500 })), + ); + const user = userEvent.setup(); + show(); + await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); + expect(await screen.findByRole("alert")).toBeDefined(); + server.use( + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces })), + ); + await user.click(screen.getByRole("menuitem", { name: "Retry" })); + expect(await screen.findByRole("menuitem", { name: /Alpha/ })).toBeDefined(); + expect(screen.getByRole("menuitem", { name: "Home" }).getAttribute("href")).toBe( + "/?env=preview", + ); + }); +}); diff --git a/apps/dashboard/src/components/AppMenu.tsx b/apps/dashboard/src/components/AppMenu.tsx new file mode 100644 index 0000000..d366aef --- /dev/null +++ b/apps/dashboard/src/components/AppMenu.tsx @@ -0,0 +1,96 @@ +import { Boxes, ChevronDown, House, KeyRound, Settings2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + EnvironmentLink, + EnvironmentNavLink, +} from "@/features/environment-context/EnvironmentLink"; +import { SessionStatus } from "@/features/infisical-session/AccountSettings"; +import { WorkspaceMenu } from "@/features/workspace-registry/WorkspaceMenu"; +import { useThemeStore } from "@/lib/stores/theme"; + +export function AppMenu() { + const { t } = useTranslation(); + const { mode } = useThemeStore(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/AppSidebar.tsx b/apps/dashboard/src/components/AppSidebar.tsx deleted file mode 100644 index 233d349..0000000 --- a/apps/dashboard/src/components/AppSidebar.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { KeyRound, House, Menu, MoonStar, Settings2, SunMedium } from "lucide-react"; -import type React from "react"; -import { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { LanguageSwitcher } from "@/components/LanguageSwitcher"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { - EnvironmentLink, - EnvironmentNavLink, -} from "@/features/environment-context/EnvironmentLink"; -import { SessionStatus } from "@/features/infisical-session/AccountSettings"; -import { WorkspaceRail } from "@/features/workspace-registry/WorkspaceRail"; -import { useThemeStore } from "@/lib/stores/theme"; -import { cn } from "@/lib/utils"; - -const navItemClass = ({ isActive }: { isActive: boolean }) => - cn( - "relative flex min-h-10 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors duration-150", - isActive - ? "bg-sidebar-active font-medium text-primary-text" - : "text-sidebar-muted hover:bg-muted hover:text-sidebar-foreground", - ); - -function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { - const { mode, toggle } = useThemeStore(); - const { t } = useTranslation(); - return ( - -
{ - if ((event.target as HTMLElement).closest("a")) onNavigate?.(); - }} - > - - - One CLI - Dashboard - - - - - - -
- cn(navItemClass({ isActive }), "min-w-0 flex-1")} - > - - {t("sidebar.settings")} - - - - - - - - {mode === "light" ? t("sidebar.themeToDark") : t("sidebar.themeToLight")} - - -
-
-
- ); -} - -export const AppSidebar: React.FC = () => ( - -); - -export function MobileNavigation() { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const navigationRef = useRef(null); - return ( - - - - - { - event.preventDefault(); - navigationRef.current?.querySelector("a")?.focus(); - }} - side="left" - closeLabel={t("sidebar.closeNavigation")} - className="w-80 gap-0 rounded-none bg-sidebar [&>button]:top-3 [&>button]:right-2" - > - {t("sidebar.navigation")} - {t("workspaces.home.description")} - setOpen(false)} /> - - - ); -} diff --git a/apps/dashboard/src/components/LanguageSwitcher.tsx b/apps/dashboard/src/components/LanguageSwitcher.tsx index b734cc0..c3758ce 100644 --- a/apps/dashboard/src/components/LanguageSwitcher.tsx +++ b/apps/dashboard/src/components/LanguageSwitcher.tsx @@ -1,6 +1,4 @@ -// LanguageSwitcher: globe-icon dropdown with three options -// (auto / zh-CN / en-US). Mirrors the theme toggle's footprint in -// the sidebar — same Button size, same icon-only collapsed state. +// Language controls share the existing auto / zh-CN / en-US preference. // // Uses Radix DropdownMenu so the menu inherits the design system's // focus/keyboard behaviour for free. @@ -28,12 +26,8 @@ const OPTIONS: Option[] = [ { mode: "en-US", labelKey: "sidebar.languageEn" }, ]; -const MENU_WIDTH = 160; -const TRIGGER_WIDTH = 28; -const CENTERED_END_OFFSET = -(MENU_WIDTH - TRIGGER_WIDTH) / 2; - export function LanguageSwitcher({ showLabel = false }: { showLabel?: boolean }) { - const { mode, setMode } = useLocaleStore(); + const { mode } = useLocaleStore(); const { t } = useTranslation(); return ( @@ -41,7 +35,6 @@ export function LanguageSwitcher({ showLabel = false }: { showLabel?: boolean }) - setMode(value as LocaleMode)} - > - {OPTIONS.map((option) => ( - - {t(option.labelKey)} - - ))} - + ); } + +function LanguageMenuItems() { + const { mode, setMode } = useLocaleStore(); + const { t } = useTranslation(); + return ( + setMode(value as LocaleMode)} + aria-label={t("sidebar.language")} + className="space-y-1" + > + {OPTIONS.map((option) => ( + + {t(option.labelKey)} + + ))} + + ); +} diff --git a/apps/dashboard/src/components/TopBar.tsx b/apps/dashboard/src/components/TopBar.tsx index 8d91cf8..7518416 100644 --- a/apps/dashboard/src/components/TopBar.tsx +++ b/apps/dashboard/src/components/TopBar.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { MoonStar, SunMedium } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useLocation, useMatch } from "react-router-dom"; import useSWR from "swr"; @@ -13,7 +14,10 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { MobileNavigation } from "@/components/AppSidebar"; +import { AppMenu } from "@/components/AppMenu"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { Button } from "@/components/ui/button"; +import { useThemeStore } from "@/lib/stores/theme"; import type { SectionKey } from "@/types/api"; export { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; interface TopBarProps { @@ -22,10 +26,13 @@ interface TopBarProps { export const TopBar: React.FC = () => { const { t } = useTranslation(); + const { mode, toggle } = useThemeStore(); + const themeLabel = t(mode === "light" ? "sidebar.themeToDark" : "sidebar.themeToLight"); const sectionMatch = useMatch("/section/:domain/:backend"); const profileMatch = useMatch("/profile"); const settingsSectionMatch = useMatch("/settings/:domain/:backend"); const settingsMatch = useMatch("/settings"); + const templatesMatch = useMatch("/templates"); const globalMatch = useMatch("/global"); const workspaceMatch = useMatch("/workspace/:entryId"); @@ -33,9 +40,10 @@ export const TopBar: React.FC = () => { const detailMatch = settingsSectionMatch ?? sectionMatch; return ( -
+
- + +
+
+ + +
); }; diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx index 8c0fd5f..ba7fb2b 100644 --- a/apps/dashboard/src/components/ui/dropdown-menu.tsx +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -1,4 +1,4 @@ -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -107,14 +107,17 @@ function DropdownMenuRadioItem({ - + {children} diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx index 1e2017b..4cc33aa 100644 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx @@ -2,10 +2,12 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { SWRConfig } from "swr"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "@/api/session"; import i18n from "@/lib/i18n"; import { AccountSettings } from "./AccountSettings"; +import { useThemeStore } from "@/lib/stores/theme"; +import { ThemeProvider } from "@/providers/ThemeProvider"; vi.mock("@/api/session", async (original) => ({ ...(await original()), getSession: vi.fn(), @@ -15,15 +17,24 @@ vi.mock("@/api/session", async (original) => ({ })); beforeEach(async () => { vi.resetAllMocks(); + useThemeStore.setState({ mode: "light" }); await i18n.changeLanguage("en-US"); }); +afterEach(() => { + localStorage.removeItem("app_theme_mode"); + document.documentElement.classList.remove("dark", "light"); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.style.colorScheme = ""; +}); function mount() { return render( new Map(), dedupingInterval: 0, shouldRetryOnError: false }} > - + + + , ); @@ -66,3 +77,28 @@ describe("account state and recovery", () => { expect(api.startLogin).not.toHaveBeenCalled(); }); }); + +describe("theme preference", () => { + it.each(["en-US", "zh-CN"])( + "saves the theme and follows shared theme changes in %s", + async (locale) => { + await i18n.changeLanguage(locale); + vi.mocked(api.getSession).mockResolvedValue({ session: { loggedIn: false, expired: false } }); + const user = userEvent.setup(); + mount(); + await user.click(screen.getByRole("combobox", { name: i18n.t("session.theme") })); + await user.click(await screen.findByRole("option", { name: i18n.t("session.themeDark") })); + expect(useThemeStore.getState().mode).toBe("dark"); + expect(localStorage.getItem("app_theme_mode")).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + await act(async () => { + useThemeStore.getState().toggle(); + }); + expect(screen.getByRole("combobox", { name: i18n.t("session.theme") }).textContent).toBe( + i18n.t("session.themeLight"), + ); + expect(localStorage.getItem("app_theme_mode")).toBe("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }, + ); +}); diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx index da6ecd5..a582171 100644 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx @@ -4,6 +4,8 @@ import { Languages, LogIn, LogOut, + MoonStar, + SunMedium, RefreshCw, Settings2, ShieldCheck, @@ -22,9 +24,18 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useThemeStore, type ThemeMode } from "@/lib/stores/theme"; export function AccountSettings() { const { t } = useTranslation(); + const { mode: theme, setMode: setTheme } = useThemeStore(); const state = useSWR(sessionKey, getSession, { refreshInterval: 2000 }); const { mutate } = useSWRConfig(); const [site, setSite] = useState("https://app.infisical.com"); @@ -228,6 +239,34 @@ export function AccountSettings() { + + + + + +
); } diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx new file mode 100644 index 0000000..cd87bb5 --- /dev/null +++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx @@ -0,0 +1,182 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { SWRConfig } from "swr"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog"; +import i18n from "@/lib/i18n"; + +const templates = [ + { + id: "react-spa", + name: "React", + description: "React", + category: "frontend", + directory: "apps", + toolchain: "node", + }, + { + id: "go-api", + name: "Go", + description: "Go", + category: "backend", + directory: "services", + toolchain: "go", + }, +]; +const server = setupServer(); +function show(projectNames: string[] = []) { + const onClose = vi.fn(); + const onCreated = vi.fn(); + render( + new Map(), shouldRetryOnError: false }}> + + , + ); + return { onClose, onCreated }; +} + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(async () => { + await i18n.changeLanguage("en-US"); + server.use( + http.get("http://localhost/api/project-templates", () => + HttpResponse.json({ templates: [...templates].reverse() }), + ), + ); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("project creation", () => { + it.each(["en-US", "zh-CN"])("creates the selected template in %s", async (locale) => { + await i18n.changeLanguage(locale); + const user = userEvent.setup(); + let payload: unknown; + server.use( + http.post( + "http://localhost/api/workspaces/:entryId/projects", + async ({ params, request }) => { + expect(params.entryId).toBe("selected-workspace"); + payload = await request.json(); + return HttpResponse.json( + { name: "api", relativeDir: "services/api", templateId: "go-api" }, + { status: 201 }, + ); + }, + ), + ); + const { onClose, onCreated } = show(); + await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "api"); + const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); + await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false)); + await user.click(select); + await user.click( + await screen.findByRole("option", { name: i18n.t("projectCreate.templates.go-api.name") }), + ); + expect(screen.getByText("services/api")).toBeDefined(); + await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("api")); + expect(payload).toEqual({ name: "api", templateId: "go-api" }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("validates names and duplicates without sending requests", async () => { + const user = userEvent.setup(); + const post = vi.fn(); + server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); + show(["web"]); + const name = screen.getByLabelText("Project name"); + const submit = screen.getByRole("button", { name: "Create project" }); + await waitFor(() => expect((submit as HTMLButtonElement).disabled).toBe(false)); + for (const value of ["../outside", "web"]) { + await user.clear(name); + await user.type(name, value); + await user.click(submit); + expect(name.getAttribute("aria-invalid")).toBe("true"); + expect(document.activeElement).toBe(name); + expect(screen.getByRole("alert")).toBeDefined(); + } + expect(post).not.toHaveBeenCalled(); + }); + + it("retains input on server errors and allows retry", async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + http.post("http://localhost/api/workspaces/:entryId/projects", () => { + calls++; + if (calls === 1) + return HttpResponse.json( + { error: { code: "TARGET_EXISTS", message: "Directory already exists" } }, + { status: 409 }, + ); + return HttpResponse.json( + { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, + { status: 201 }, + ); + }), + ); + const { onCreated } = show(); + await user.type(screen.getByLabelText("Project name"), "web"); + await user.click(screen.getByRole("button", { name: "Create project" })); + expect(await screen.findByText("Directory already exists")).toBeDefined(); + expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("web"); + await user.click(screen.getByRole("button", { name: "Create project" })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith("web")); + }); + + it("blocks duplicate submissions and closing while creation is pending", async () => { + const user = userEvent.setup(); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const post = vi.fn(async () => { + await blocked; + return HttpResponse.json( + { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, + { status: 201 }, + ); + }); + server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); + const { onClose } = show(); + await user.type(screen.getByLabelText("Project name"), "web"); + await user.dblClick(screen.getByRole("button", { name: "Create project" })); + expect(await screen.findByRole("status")).toBeDefined(); + expect((screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement).disabled).toBe( + true, + ); + await user.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledOnce(); + release(); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + }); + + it("recovers from template loading failure and updates translated template labels", async () => { + const user = userEvent.setup(); + server.use( + http.get( + "http://localhost/api/project-templates", + () => new HttpResponse(null, { status: 500 }), + ), + ); + show(); + expect(await screen.findByText("Could not load templates")).toBeDefined(); + server.use( + http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), + ); + await user.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByRole("combobox", { name: "Technology stack" }); + await i18n.changeLanguage("zh-CN"); + const dialog = await screen.findByRole("dialog", { name: "新建项目" }); + expect(await within(dialog).findByText("React 单页应用")).toBeDefined(); + }); +}); diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx new file mode 100644 index 0000000..999d077 --- /dev/null +++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx @@ -0,0 +1,238 @@ +import { useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR, { useSWRConfig } from "swr"; +import { createProject, getProjectTemplates, projectTemplatesKey } from "@/api/project-creation"; +import { workspaceBasePath, workspacesKey } from "@/api/workspaces"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { useToast } from "@/hooks/useToast"; +import type { HttpError } from "@/types/api"; + +interface Props { + entryId: string; + projectNames: string[]; + onClose(): void; + onCreated(name: string): void; +} + +export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated }: Props) { + const { t } = useTranslation(); + const { mutate } = useSWRConfig(); + const toast = useToast(); + const id = useId(); + const nameInput = useRef(null); + const submitting = useRef(false); + const [name, setName] = useState(""); + const [templateId, setTemplateId] = useState("react-spa"); + const [pending, setPending] = useState(false); + const [nameError, setNameError] = useState(""); + const [failure, setFailure] = useState(); + const catalog = useSWR(projectTemplatesKey, getProjectTemplates); + const templates = catalog.data?.templates ?? []; + const selected = templates.find((entry) => entry.id === templateId) ?? templates[0]; + const cleanName = name.trim(); + const validName = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(cleanName); + const categoryKeys = ["frontend", "backend", "library"] as const; + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (submitting.current || !selected) return; + const validation = !validName + ? "projectCreate.nameInvalid" + : projectNames.includes(cleanName) + ? "projectCreate.nameExists" + : ""; + setNameError(validation); + if (validation) { + nameInput.current?.focus(); + return; + } + submitting.current = true; + setPending(true); + setFailure(undefined); + let result; + try { + result = await createProject(entryId, { name: cleanName, templateId: selected.id }); + } catch (cause) { + setFailure(cause as HttpError); + setPending(false); + submitting.current = false; + return; + } + // A refresh failure must never turn a successful creation into a retry. + const refreshed = await Promise.allSettled([ + mutate((key) => typeof key === "string" && key.startsWith(`${workspaceBasePath(entryId)}/`)), + mutate(workspacesKey), + ]); + onCreated(result.name); + toast.success(t("projectCreate.success", { name: result.name })); + for (const warning of result.warnings ?? []) toast.warning(warning); + if (refreshed.some((item) => item.status === "rejected")) + toast.warning(t("projectCreate.refreshFailed")); + onClose(); + } + + return ( + { + if (!open && !submitting.current) onClose(); + }} + > + event.preventDefault()} + > + + {t("projectCreate.title")} + {t("projectCreate.description")} + +
+ + {t("projectCreate.name")} + { + setName(event.target.value); + setNameError(""); + }} + /> + + {t(nameError || "projectCreate.nameHint")} + + + + {t("projectCreate.template")} + {catalog.error ? ( + + {t("projectCreate.templatesFailed")} + + + + + ) : !selected ? ( +

+ {t( + catalog.isLoading + ? "projectCreate.loadingTemplates" + : "projectCreate.noTemplates", + )} +

+ ) : ( + <> + + {selected && ( + + {t(`projectCreate.templates.${selected.id}.description`, { + defaultValue: selected.description, + })} + + )} + + )} +
+ {selected && ( +
+

{t("projectCreate.location")}

+

+ {selected.directory}/{validName ? cleanName : "…"} +

+

+ {t("projectCreate.locationHint")} +

+
+ )} + {failure && ( + + {t("projectCreate.failed")} + + {failure.status === 0 ? t("projectCreate.connectionLost") : failure.message} + + + )} + {pending && ( +

+

+ )} + + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx index 2468abe..a792b8e 100644 --- a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx @@ -2,12 +2,22 @@ import { manifestDraftKey, useManifestDraftStore, } from "@/features/manifest-draft/manifest-draft-store"; -import { Server, Code2, FileKey2, Library, Search, LayoutGrid, LockKeyhole } from "lucide-react"; +import { + Plus, + Server, + Code2, + FileKey2, + Library, + Search, + LayoutGrid, + LockKeyhole, +} from "lucide-react"; import type React from "react"; import { useEffect, useId, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { getProjectSettings, projectSettingsKey } from "@/api/workspace"; +import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog"; import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { @@ -67,6 +77,8 @@ export const ProjectInspector: React.FC = ({ const { t } = useTranslation(); const dirtyOwner = useId(); const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const canCreate = !!workspaceEntryId && !readOnly; const [dirty, setDirty] = useState(false); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); const [selectedName, setSelectedName] = useState(projects[0]?.name ?? ""); @@ -176,6 +188,16 @@ export const ProjectInspector: React.FC = ({ ) : null}
) : null} + {canCreate && ( + + )}
@@ -207,7 +229,17 @@ export const ProjectInspector: React.FC = ({ {selectedProject?.relativeDir}

-
+
+ {canCreate && ( + + )} = ({
+ {createOpen && canCreate && workspaceEntryId && ( + project.name)} + onClose={() => setCreateOpen(false)} + onCreated={(name) => { + setQuery(""); + setInspectorDirty(false); + setSelectedName(name); + }} + /> + )} + { diff --git a/apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx b/apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx new file mode 100644 index 0000000..1281fba --- /dev/null +++ b/apps/dashboard/src/features/workspace-registry/WorkspaceMenu.tsx @@ -0,0 +1,93 @@ +import { Check } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useMatch } from "react-router-dom"; +import useSWR from "swr"; +import { getWorkspaces, workspacesKey } from "@/api/workspaces"; +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; +import { cn } from "@/lib/utils"; +import type { WorkspaceRegistryStatus } from "@/types/api"; + +const STATUS_DOT_CLASS: Record = { + ready: "bg-success-foreground", + missing: "bg-muted-foreground", + invalid: "bg-error-foreground", + "identity-missing": "bg-warning-foreground", + "identity-conflict": "bg-warning-foreground", +}; + +export function WorkspaceMenu() { + const { t } = useTranslation(); + const activeEntryId = useMatch("/workspace/:entryId")?.params.entryId; + const registry = useSWR(workspacesKey, getWorkspaces); + const workspaces = registry.data?.workspaces ?? []; + return ( + <> + + {t("workspaces.rail.title")} + {registry.data ? workspaces.length : ""} + + + {registry.isLoading && ( +

+ {t("workspaces.loading")} +

+ )} + {registry.error && ( + <> +

+ {t("workspaces.rail.loadFailed")} +

+ { + event.preventDefault(); + void registry.mutate().catch(() => undefined); + }} + > + {t("workspaces.retry")} + + + )} + {!registry.isLoading && !registry.error && workspaces.length === 0 && ( +

+ {t("workspaces.rail.empty")} +

+ )} + {workspaces.map((workspace) => { + const active = workspace.entryId === activeEntryId; + return ( + + + + {workspace.name} + + {workspace.projectCount} + + {active && + + ); + })} +
+ + ); +} diff --git a/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx b/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx deleted file mode 100644 index ee69cc2..0000000 --- a/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { Trash2 } from "lucide-react"; -import type React from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useLocation, useMatch, useNavigate } from "react-router-dom"; -import useSWR from "swr"; -import { forgetWorkspace, getWorkspaces, workspacesKey } from "@/api/workspaces"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { preserveEnvironment } from "@/features/environment-context/environment"; -import { useToast } from "@/hooks/useToast"; -import { cn } from "@/lib/utils"; -import type { WorkspaceRegistryEntry, WorkspaceRegistryStatus } from "@/types/api"; - -const STATUS_DOT_CLASS: Record = { - ready: "bg-success-foreground", - missing: "bg-muted-foreground", - invalid: "bg-error-foreground", - "identity-missing": "bg-warning-foreground", - "identity-conflict": "bg-warning-foreground", -}; - -const WorkspaceRailItem: React.FC<{ - workspace: WorkspaceRegistryEntry; - active: boolean; - forgetting: boolean; - onForget(): void; -}> = ({ workspace, active, forgetting, onForget }) => { - const { t } = useTranslation(); - - return ( -
- {active ? : null} - - - - - {workspace.name} - - - {workspace.projectCount} - - - - - - - - {t("workspaces.forget.short")} - -
- ); -}; - -export const WorkspaceRail: React.FC = () => { - const { t } = useTranslation(); - const toast = useToast(); - const navigate = useNavigate(); - const { search } = useLocation(); - const workspaceMatch = useMatch("/workspace/:entryId"); - const activeEntryId = workspaceMatch?.params.entryId; - const registry = useSWR(workspacesKey, getWorkspaces); - const [forgetting, setForgetting] = useState(null); - const [workspaceToForget, setWorkspaceToForget] = useState(null); - const workspaces = registry.data?.workspaces ?? []; - - async function handleForget(workspace: WorkspaceRegistryEntry) { - setForgetting(workspace.entryId); - try { - await forgetWorkspace(workspace.entryId); - await registry.mutate( - (current) => - current - ? { - ...current, - currentEntryId: - current.currentEntryId === workspace.entryId ? undefined : current.currentEntryId, - workspaces: current.workspaces.filter( - (entry) => entry.entryId !== workspace.entryId, - ), - } - : current, - { revalidate: false }, - ); - if (activeEntryId === workspace.entryId) { - navigate(preserveEnvironment("/", search), { replace: true }); - } - toast.success(t("workspaces.forget.done", { name: workspace.name })); - setWorkspaceToForget(null); - } catch (error) { - const failure = error as { message?: string }; - toast.error(t("workspaces.forget.failed"), { - description: failure.message ?? String(error), - }); - } finally { - setForgetting(null); - } - } - - return ( - <> -
-
-
-

{t("workspaces.rail.title")}

-
- {workspaces.length > 0 ? ( - {workspaces.length} - ) : null} -
- - - {/* Constrain the content width inside Radix ScrollArea's intrinsic table wrapper. */} -
- {registry.isLoading ? : null} - {registry.error ? ( -

- {t("workspaces.rail.loadFailed")} -

- ) : null} - {!registry.isLoading && !registry.error && workspaces.length === 0 ? ( -

- {t("workspaces.rail.empty")} -

- ) : null} - {workspaces.map((workspace) => ( - setWorkspaceToForget(workspace)} - /> - ))} -
-
-
- - { - if (!open && !forgetting) setWorkspaceToForget(null); - }} - > - - - {t("workspaces.forget.short")} - - {workspaceToForget - ? t("workspaces.forget.confirm", { name: workspaceToForget.name }) - : ""} - - - - {t("form.cancel")} - { - event.preventDefault(); - if (workspaceToForget) void handleForget(workspaceToForget); - }} - > - {t("workspaces.forget.short")} - - - - - - ); -}; - -const WorkspaceRailLoading: React.FC = () => { - const { t } = useTranslation(); - return ( -
- - -
- ); -}; diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index fcfaf7e..483580b 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -562,7 +562,11 @@ "settingsHint": "Manage your connected account and dashboard preferences.", "connected": "Connected", "account": "Account", - "languageHint": "Choose a language or follow your system setting." + "languageHint": "Choose a language or follow your system setting.", + "theme": "Appearance", + "themeHint": "Choose a light or dark theme. Your choice is saved in this browser.", + "themeLight": "Light", + "themeDark": "Dark" }, "global": { "title": "Shared credentials", @@ -618,5 +622,162 @@ "noMatches": "No matching variables", "noMatchesHint": "Try another name or clear the search.", "emptyHint": "Add a variable to share it across your projects." + }, + "projectCreate": { + "title": "New project", + "description": "Create a project from a template in this workspace.", + "name": "Project name", + "nameHint": "Start with a letter or number. Use letters, numbers, hyphens or underscores.", + "nameInvalid": "Enter a name starting with a letter or number, using only letters, numbers, hyphens or underscores.", + "nameExists": "A project with this name already exists in the workspace.", + "template": "Technology stack", + "templatesFailed": "Could not load templates", + "loadingTemplates": "Loading templates…", + "noTemplates": "No templates available", + "location": "Project directory", + "locationHint": "Relative to this workspace. Creating the project also updates the workspace configuration.", + "submit": "Create project", + "creating": "Creating project…", + "failed": "Could not create project", + "success": "Project {{name}} created", + "refreshFailed": "Project created. Refresh the page to load the updated workspace.", + "connectionLost": "The connection was interrupted. Refresh the project list to check whether the project was created before trying again.", + "categories": { + "frontend": "Applications", + "backend": "Services", + "library": "Libraries" + }, + "templates": { + "nestjs-api": { + "name": "NestJS API service", + "description": "NestJS + TypeScript for APIs and backend services" + }, + "go-api": { + "name": "Go API service", + "description": "Gin + Gorm + Viper + Zap for APIs and backend services" + }, + "astro-site": { + "name": "Astro static site", + "description": "Static site template for company, marketing, and content websites" + }, + "starlight-docs": { + "name": "Starlight documentation", + "description": "Documentation project based on Astro + Starlight" + }, + "nextjs-app": { + "name": "Next.js application", + "description": "Next.js application for SEO and fast initial page loads" + }, + "react-spa": { + "name": "React single-page application", + "description": "React + Vite template for interactive applications" + }, + "expo-mobile": { + "name": "Expo mobile app", + "description": "React Native + Expo template for iOS and Android" + }, + "ts-library": { + "name": "TypeScript library", + "description": "Publishable library with tsdown, vitest, and oxlint/oxfmt" + }, + "go-lib": { + "name": "Go library", + "description": "Go library following golang-standards/project-layout, ready to publish and import" + }, + "electron-app": { + "name": "Electron desktop app", + "description": "Electron + React + Vite with inversify, electron-log, electron-store, and electron-updater" + } + } + }, + "appMenu": { + "label": "One CLI navigation menu" + }, + "templateCatalog": { + "title": "Project templates", + "description": "Explore the built-in starters and compare their use cases, technology stacks, and included features.", + "getStarted": "Start a project from a template", + "getStartedHint": "Open a workspace, click New project, then choose a template and enter a project name.", + "chooseWorkspace": "Choose a workspace", + "filter": "Filter templates by category", + "all": "All templates", + "search": "Search templates, stacks, or features…", + "resultCount": "Showing {{count}} of {{total}} templates", + "includes": "Included features", + "noResults": "No matching templates", + "noResultsHint": "Try another search term or select a different category.", + "reset": "Clear filters", + "templates": { + "nestjs-api": { + "description": "Build TypeScript APIs and business backends with a modular NestJS structure.", + "stack": "NestJS · TypeScript · Drizzle", + "feature1": "JWT authentication and user modules", + "feature2": "Drizzle data access and database migrations", + "feature3": "Swagger documentation, health checks, and metrics" + }, + "go-api": { + "description": "Build Go APIs with separate HTTP, service, and data-access layers.", + "stack": "Go · Gin · Gorm · Viper · Zap", + "feature1": "JWT authentication and user endpoints", + "feature2": "Gorm data access and SQL migrations", + "feature3": "OpenAPI documentation, configuration, and logging" + }, + "astro-site": { + "description": "Create company websites, landing pages, and content sites with static page output.", + "stack": "Astro · TypeScript · Tailwind CSS", + "feature1": "File-based pages and shared layouts", + "feature2": "Shared design tokens and Tailwind styles", + "feature3": "Type checking, linting, and formatting" + }, + "starlight-docs": { + "description": "Publish product documentation, developer guides, and team knowledge bases.", + "stack": "Astro · Starlight · Markdown / MDX", + "feature1": "Markdown and MDX documentation pages", + "feature2": "Guide and reference sections with a configured sidebar", + "feature3": "Customizable documentation theme" + }, + "nextjs-app": { + "description": "Build web applications with server rendering, API routes, and search-friendly pages.", + "stack": "Next.js · React · TypeScript", + "feature1": "App Router pages and route handlers", + "feature2": "shadcn/ui components and theme switching", + "feature3": "SWR data fetching and Zustand state management" + }, + "react-spa": { + "description": "Create interactive dashboards, admin panels, and browser-based business applications.", + "stack": "React · Vite · TypeScript", + "feature1": "Client-side routing and error boundaries", + "feature2": "shadcn/ui components and light / dark themes", + "feature3": "SWR data fetching and Zustand state management" + }, + "expo-mobile": { + "description": "Build iOS and Android apps with React Native and a shared Expo project.", + "stack": "Expo · React Native · NativeWind", + "feature1": "Expo Router navigation and tab pages", + "feature2": "SWR, Zustand, and MMKV local storage", + "feature3": "Native modules use an Expo development build" + }, + "ts-library": { + "description": "Create reusable TypeScript modules and packages for publishing to npm.", + "stack": "TypeScript · tsdown · Vitest", + "feature1": "Library builds and type declarations with tsdown", + "feature2": "Unit tests and coverage with Vitest", + "feature3": "Linting, formatting, and publishing scripts" + }, + "go-lib": { + "description": "Create reusable Go modules with a public package layout for other projects to import.", + "stack": "Go · Go modules · go-task", + "feature1": "Public API starter in pkg/greeter", + "feature2": "Example package and unit tests", + "feature3": "Build, format, vet, and test tasks" + }, + "electron-app": { + "description": "Create desktop applications with an Electron main process and a React user interface.", + "stack": "Electron · React · Vite · Inversify", + "feature1": "Separate main, renderer, and preload packages", + "feature2": "Typed IPC bridge and dependency injection", + "feature3": "Logging, local settings, and updater modules" + } + } } } diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 218943a..13b7fc5 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -562,7 +562,11 @@ "settingsHint": "管理已连接的账号与界面偏好。", "connected": "已连接", "account": "账号", - "languageHint": "选择显示语言,或跟随系统设置。" + "languageHint": "选择显示语言,或跟随系统设置。", + "theme": "外观主题", + "themeHint": "选择浅色或深色主题,偏好会保存在当前浏览器中。", + "themeLight": "浅色", + "themeDark": "深色" }, "global": { "title": "共享凭据", @@ -618,5 +622,162 @@ "noMatches": "没有匹配的变量", "noMatchesHint": "尝试其他名称,或清除搜索条件。", "emptyHint": "添加变量,让多个项目共享使用。" + }, + "projectCreate": { + "title": "新建项目", + "description": "在当前工作区中,使用模板创建一个项目。", + "name": "项目名称", + "nameHint": "以字母或数字开头,可使用字母、数字、连字符和下划线。", + "nameInvalid": "请输入以字母或数字开头,且仅包含字母、数字、连字符或下划线的名称。", + "nameExists": "当前工作区已存在同名项目,请使用其他名称。", + "template": "技术栈", + "templatesFailed": "无法加载模板", + "loadingTemplates": "正在加载模板…", + "noTemplates": "暂无可用模板", + "location": "项目目录", + "locationHint": "相对于当前工作区。创建项目时会同步更新工作区配置。", + "submit": "创建项目", + "creating": "正在创建项目…", + "failed": "创建项目失败", + "success": "项目 {{name}} 已创建", + "refreshFailed": "项目已创建,请刷新页面加载最新工作区。", + "connectionLost": "连接已中断,请先刷新项目列表确认是否已创建,再尝试提交。", + "categories": { + "frontend": "应用", + "backend": "服务", + "library": "库" + }, + "templates": { + "nestjs-api": { + "name": "NestJS API 服务", + "description": "NestJS + TypeScript,适合 API 服务与业务后台" + }, + "go-api": { + "name": "Go API 服务", + "description": "Gin + Gorm + Viper + Zap,适合 API 服务与业务后台" + }, + "astro-site": { + "name": "Astro 静态网站", + "description": "前端静态站模板,适合官网、营销站与内容站点" + }, + "starlight-docs": { + "name": "Starlight 文档站点", + "description": "基于 Astro + Starlight 的文档项目模板" + }, + "nextjs-app": { + "name": "Next.js 全栈应用", + "description": "Next.js 前端模板,适合 SEO 与首屏性能要求较高场景" + }, + "react-spa": { + "name": "React 单页应用", + "description": "React + Vite 单页应用模板,适合交互型业务系统" + }, + "expo-mobile": { + "name": "Expo 移动 App", + "description": "React Native + Expo App 模板,适合 iOS/Android 双端开发" + }, + "ts-library": { + "name": "TypeScript 通用库", + "description": "通用库模板,适合发包场景,内置 tsdown + vitest + oxlint/oxfmt" + }, + "go-lib": { + "name": "Go 通用库", + "description": "Go 库模板,遵循 golang-standards/project-layout,适合发包与被 import 场景" + }, + "electron-app": { + "name": "Electron 桌面应用", + "description": "Electron + React + Vite 桌面应用模板,主进程基于 inversify,内置 electron-log/store/updater" + } + } + }, + "appMenu": { + "label": "One CLI 导航菜单" + }, + "templateCatalog": { + "title": "项目模板", + "description": "浏览内置项目模板,了解适用场景、技术栈和内置功能,选择合适的项目起点。", + "getStarted": "从模板开始创建项目", + "getStartedHint": "打开工作区,点击「新建项目」,选择模板并填写项目名称即可创建。", + "chooseWorkspace": "选择工作区", + "filter": "按分类筛选模板", + "all": "全部模板", + "search": "搜索模板、技术栈或功能…", + "resultCount": "显示 {{count}} / {{total}} 个模板", + "includes": "内置功能", + "noResults": "没有匹配的模板", + "noResultsHint": "试试其他关键词,或切换模板分类。", + "reset": "清除筛选", + "templates": { + "nestjs-api": { + "description": "使用模块化 NestJS 架构构建 TypeScript API,适合业务后台与后端服务。", + "stack": "NestJS · TypeScript · Drizzle", + "feature1": "JWT 鉴权与用户业务模块", + "feature2": "Drizzle 数据访问与数据库迁移", + "feature3": "Swagger 文档、健康检查与监控指标" + }, + "go-api": { + "description": "构建 Go API 服务,按 HTTP 接口、业务逻辑和数据访问分层组织代码。", + "stack": "Go · Gin · Gorm · Viper · Zap", + "feature1": "JWT 鉴权与用户接口", + "feature2": "Gorm 数据访问与 SQL 迁移", + "feature3": "OpenAPI 文档、配置管理与日志" + }, + "astro-site": { + "description": "通过静态页面构建官网、营销落地页和内容站点,适合以内容展示为主的项目。", + "stack": "Astro · TypeScript · Tailwind CSS", + "feature1": "基于文件的页面路由与共享布局", + "feature2": "统一设计变量与 Tailwind 样式", + "feature3": "类型检查、代码检查与格式化" + }, + "starlight-docs": { + "description": "快速搭建产品文档、开发者指南和团队知识库,专注于文档内容的编写。", + "stack": "Astro · Starlight · Markdown / MDX", + "feature1": "Markdown 与 MDX 文档页面", + "feature2": "指南、参考文档与预设侧边栏", + "feature3": "可定制的文档主题样式" + }, + "nextjs-app": { + "description": "构建需要服务端渲染、API 路由和搜索引擎友好页面的 Web 应用。", + "stack": "Next.js · React · TypeScript", + "feature1": "App Router 页面与接口路由", + "feature2": "shadcn/ui 组件与主题切换", + "feature3": "SWR 数据请求与 Zustand 状态管理" + }, + "react-spa": { + "description": "构建交互丰富的仪表盘、管理后台和浏览器端业务系统。", + "stack": "React · Vite · TypeScript", + "feature1": "客户端路由与错误边界", + "feature2": "shadcn/ui 组件与明暗主题", + "feature3": "SWR 数据请求与 Zustand 状态管理" + }, + "expo-mobile": { + "description": "基于 React Native 和 Expo,以同一项目开发 iOS 与 Android 移动应用。", + "stack": "Expo · React Native · NativeWind", + "feature1": "Expo Router 导航与标签页面", + "feature2": "SWR、Zustand 与 MMKV 本地存储", + "feature3": "原生模块通过 Expo 开发构建运行" + }, + "ts-library": { + "description": "开发可复用的 TypeScript 模块,适合共享工具库和发布到 npm 的包。", + "stack": "TypeScript · tsdown · Vitest", + "feature1": "tsdown 库构建与类型声明生成", + "feature2": "Vitest 单元测试与覆盖率", + "feature3": "代码检查、格式化与发包脚本" + }, + "go-lib": { + "description": "开发可被其他项目导入的 Go 模块,使用清晰的公共包目录组织可复用代码。", + "stack": "Go · Go modules · go-task", + "feature1": "pkg/greeter 公共 API 起点", + "feature2": "示例包与单元测试", + "feature3": "构建、格式化、静态检查与测试任务" + }, + "electron-app": { + "description": "使用 Electron 主进程和 React 界面构建桌面应用,适合桌面工具与业务客户端。", + "stack": "Electron · React · Vite · Inversify", + "feature1": "主进程、渲染进程与 preload 分包", + "feature2": "带类型的 IPC 桥接与依赖注入", + "feature3": "日志、本地设置与更新器模块" + } + } } } diff --git a/apps/dashboard/src/pages/Overview.test.tsx b/apps/dashboard/src/pages/Overview.test.tsx index da3115d..ce75b91 100644 --- a/apps/dashboard/src/pages/Overview.test.tsx +++ b/apps/dashboard/src/pages/Overview.test.tsx @@ -286,6 +286,53 @@ describe("workspace overview Profile-only configuration", () => { }); afterAll(() => server.close()); + it("creates a project from the empty workspace and selects it after refreshing", async () => { + const user = userEvent.setup(); + let created = false; + server.use( + http.get("http://localhost/api/project-templates", () => + HttpResponse.json({ + templates: [ + { + id: "react-spa", + name: "React", + description: "React", + category: "frontend", + directory: "apps", + toolchain: "node", + }, + ], + }), + ), + http.post("http://localhost/api/workspaces/demo-entry/projects", async ({ request }) => { + expect(await request.json()).toEqual({ name: "web", templateId: "react-spa" }); + created = true; + return HttpResponse.json( + { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, + { status: 201 }, + ); + }), + http.get("http://localhost/api/workspaces/demo-entry/overview", () => + HttpResponse.json({ ...overview, projects: created ? [overview.projects![0]] : [] }), + ), + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces: [] })), + ); + renderOverview({ ...overview, projects: [] }, "demo-entry", false, true); + const navigation = screen.getByRole("navigation", { name: "Workspace projects" }); + await user.click(within(navigation).getByRole("button", { name: "New project" })); + const dialog = await screen.findByRole("dialog", { name: "New project" }); + await user.type(within(dialog).getByLabelText("Project name"), "web"); + await user.click(within(dialog).getByRole("button", { name: "Create project" })); + const project = await within(navigation).findByRole("button", { name: "web apps/web" }); + expect(project.getAttribute("aria-current")).toBe("page"); + expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull(); + }); + + it("hides project creation for an identity-conflicted workspace", () => { + renderOverview({ ...overview, projects: [] }, "demo-entry", true); + expect(screen.queryByRole("button", { name: "New project" })).toBeNull(); + }); + it("shows projects in a persistent left-hand list with project settings tabs", async () => { renderOverview(); diff --git a/apps/dashboard/src/pages/ProjectTemplates.test.tsx b/apps/dashboard/src/pages/ProjectTemplates.test.tsx new file mode 100644 index 0000000..1d62042 --- /dev/null +++ b/apps/dashboard/src/pages/ProjectTemplates.test.tsx @@ -0,0 +1,153 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { MemoryRouter } from "react-router-dom"; +import { SWRConfig } from "swr"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { App } from "@/App"; +import { ProjectTemplates } from "@/pages/ProjectTemplates"; +import i18n from "@/lib/i18n"; + +const registry = JSON.parse( + readFileSync(resolve(process.cwd(), "../../packages/templates/registry.json"), "utf8"), +); +const templates = registry.templates.map( + (template: { + id: string; + name: string; + description: string; + category: "frontend" | "backend" | "library"; + toolchain: string; + }) => ({ + ...template, + directory: { frontend: "apps", backend: "services", library: "packages" }[template.category], + }), +); +const server = setupServer( + http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), + http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces: [] })), + http.get("http://localhost/api/session", () => + HttpResponse.json({ session: { loggedIn: false } }), + ), +); +function show(withApp = false) { + return render( + new Map(), shouldRetryOnError: false }}> + + {withApp ? : } + + , + ); +} +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(async () => { + await i18n.changeLanguage("en-US"); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("project template catalog", () => { + it.each(["en-US", "zh-CN"])("introduces every registered template in %s", async (locale) => { + await i18n.changeLanguage(locale); + show(); + expect(await screen.findAllByRole("article")).toHaveLength(templates.length); + for (const template of templates) { + const name = i18n.t(`projectCreate.templates.${template.id}.name`); + const card = screen.getByRole("article", { name }); + for (const key of ["description", "stack", "feature1", "feature2", "feature3"]) { + const value = i18n.getResource( + locale, + "translation", + `templateCatalog.templates.${template.id}.${key}`, + ); + expect(typeof value).toBe("string"); + expect(value.length).toBeGreaterThan(0); + expect(within(card).getByText(value)).toBeDefined(); + } + expect(within(card).getByText(`${template.directory}/`)).toBeDefined(); + } + expect( + screen + .getByRole("link", { name: i18n.t("templateCatalog.chooseWorkspace") }) + .getAttribute("href"), + ).toBe("/?env=preview"); + }); + + it("combines category and feature search, and resets an empty result", async () => { + const user = userEvent.setup(); + show(); + await screen.findAllByRole("article"); + await user.click(screen.getByRole("button", { name: /Services/ })); + expect(screen.getAllByRole("article")).toHaveLength(2); + await user.type( + screen.getByRole("textbox", { name: "Search templates, stacks, or features…" }), + " DRIZZLE ", + ); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(screen.getByRole("article", { name: "NestJS API service" })).toBeDefined(); + await user.click(screen.getByRole("button", { name: /Libraries/ })); + expect(screen.queryAllByRole("article")).toHaveLength(0); + expect(screen.getByRole("heading", { name: "No matching templates" })).toBeDefined(); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(screen.getAllByRole("article")).toHaveLength(templates.length); + }); + + it("updates introductions when the language changes", async () => { + show(); + await screen.findByRole("article", { name: "React single-page application" }); + await act(async () => { + await i18n.changeLanguage("zh-CN"); + }); + expect(await screen.findByRole("article", { name: "React 单页应用" })).toBeDefined(); + expect(screen.queryByText("Client-side routing and error boundaries")).toBeNull(); + expect(screen.getByText("客户端路由与错误边界")).toBeDefined(); + }); + + it("recovers from a catalog error", async () => { + server.use( + http.get( + "http://localhost/api/project-templates", + () => new HttpResponse(null, { status: 500 }), + ), + ); + const user = userEvent.setup(); + show(); + expect(await screen.findByRole("alert")).toBeDefined(); + server.use( + http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), + ); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findAllByRole("article")).toHaveLength(templates.length); + }); + + it("shows the catalog empty state", async () => { + server.use( + http.get("http://localhost/api/project-templates", () => + HttpResponse.json({ templates: [] }), + ), + ); + show(); + expect( + await screen.findByRole("heading", { name: i18n.t("projectCreate.noTemplates") }), + ).toBeDefined(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("exposes the page through the brand menu and breadcrumb", async () => { + const user = userEvent.setup(); + show(true); + await screen.findByRole("heading", { name: "Project templates", level: 1 }); + const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" }); + expect(within(breadcrumb).getByText("Project templates")).toBeDefined(); + await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); + const item = screen.getByRole("menuitem", { name: "Project templates" }); + expect(item.getAttribute("href")).toBe("/templates?env=preview"); + expect(item.getAttribute("aria-current")).toBe("page"); + await user.click(item); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(screen.getByRole("heading", { name: "Project templates", level: 1 })).toBeDefined(); + }); +}); diff --git a/apps/dashboard/src/pages/ProjectTemplates.tsx b/apps/dashboard/src/pages/ProjectTemplates.tsx new file mode 100644 index 0000000..4a7da04 --- /dev/null +++ b/apps/dashboard/src/pages/ProjectTemplates.tsx @@ -0,0 +1,238 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { + ArrowRight, + BookOpen, + Boxes, + Braces, + Check, + Folder, + Globe, + Monitor, + Package, + Search, + Server, + Smartphone, + type LucideIcon, +} from "lucide-react"; +import { getProjectTemplates, projectTemplatesKey } from "@/api/project-creation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ErrorNotice, PageHeader, SearchInput, StatePanel } from "@/components/ui/page-layout"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; + +const CATEGORIES = ["all", "frontend", "backend", "library"] as const; +const TEMPLATE_ICONS: Record = { + "nestjs-api": Server, + "go-api": Server, + "astro-site": Globe, + "starlight-docs": BookOpen, + "nextjs-app": Globe, + "react-spa": Braces, + "expo-mobile": Smartphone, + "electron-app": Monitor, + "ts-library": Package, + "go-lib": Package, +}; + +export function ProjectTemplates() { + const { t } = useTranslation(); + const catalog = useSWR(projectTemplatesKey, getProjectTemplates); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState<(typeof CATEGORIES)[number]>("all"); + const templates = (catalog.data?.templates ?? []).map((template) => ({ + ...template, + name: t(`projectCreate.templates.${template.id}.name`, { defaultValue: template.name }), + description: t(`templateCatalog.templates.${template.id}.description`, { + defaultValue: template.description, + }), + stack: t(`templateCatalog.templates.${template.id}.stack`, { + defaultValue: template.toolchain === "go" ? "Go" : "Node.js", + }), + features: ["feature1", "feature2", "feature3"] + .map((key) => t(`templateCatalog.templates.${template.id}.${key}`, { defaultValue: "" })) + .filter(Boolean), + })); + const search = query.trim().toLocaleLowerCase(); + const filtered = templates.filter( + (template) => + (category === "all" || template.category === category) && + [ + template.name, + template.id, + template.description, + template.stack, + template.directory, + template.toolchain === "go" ? "Go" : "Node.js", + ...template.features, + ] + .join(" ") + .toLocaleLowerCase() + .includes(search), + ); + function resetFilters() { + setCategory("all"); + setQuery(""); + } + + return ( +
+ +
+
+
+ +
+ {catalog.isLoading ? ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ) : catalog.error ? ( + void catalog.mutate().catch(() => undefined)}> + {t("workspaces.retry")} + + } + > + {t("projectCreate.templatesFailed")} + + ) : templates.length === 0 ? ( + + ) : ( +
+
+
+ {CATEGORIES.map((value) => ( + + ))} +
+
+ setQuery(event.target.value)} + placeholder={t("templateCatalog.search")} + aria-label={t("templateCatalog.search")} + /> +
+
+

+ {t("templateCatalog.resultCount", { count: filtered.length, total: templates.length })} +

+ {filtered.length === 0 ? ( + + + + ) : ( +
+ {filtered.map((template) => { + const Icon = TEMPLATE_ICONS[template.id] ?? Boxes; + return ( +
+
+ + +
+

+ {template.name} +

+

+ {template.id} +

+
+
+
+ + {t(`projectCreate.categories.${template.category}`)} + +
+

+ {template.description} +

+

{template.stack}

+ {template.features.length > 0 && ( +
    + {template.features.map((feature) => ( +
  • +
  • + ))} +
+ )} +
+ + + {template.toolchain === "go" ? "Go" : "Node.js"} +
+
+ ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/router/routes.test.tsx b/apps/dashboard/src/router/routes.test.tsx index 3133299..43cca17 100644 --- a/apps/dashboard/src/router/routes.test.tsx +++ b/apps/dashboard/src/router/routes.test.tsx @@ -385,8 +385,8 @@ describe("multi-workspace routing", () => { , ); - const navigation = await screen.findAllByRole("link", { name: "Shared credentials" }); - await user.click(navigation[0]); + await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); + await user.click(await screen.findByRole("menuitem", { name: "Shared credentials" })); await screen.findByRole("heading", { name: "Shared credentials" }); await user.click(screen.getByRole("link", { name: "Sign in with browser" })); await screen.findByRole("heading", { name: "Infisical" }); diff --git a/apps/dashboard/src/router/routes.tsx b/apps/dashboard/src/router/routes.tsx index 1fca401..2ca42a7 100644 --- a/apps/dashboard/src/router/routes.tsx +++ b/apps/dashboard/src/router/routes.tsx @@ -18,6 +18,7 @@ import { Overview } from "@/pages/Overview"; import { AccountSettings } from "@/features/infisical-session/AccountSettings"; import { GlobalVariables } from "@/features/global-variables/GlobalVariables"; +import { ProjectTemplates } from "@/pages/ProjectTemplates"; import { WorkspaceHome } from "@/pages/WorkspaceHome"; import type { WorkspaceRegistryEntry } from "@/types/api"; @@ -250,6 +251,7 @@ const UnknownWorkspace: React.FC = () => { const routes: RouteObject[] = [ { path: "/", element: }, + { path: "/templates", element: }, { path: "/workspace/:entryId", element: }, { path: "/settings", element: }, { path: "/global", element: }, diff --git a/packages/cli/internal/bootstrap/cli/root.go b/packages/cli/internal/bootstrap/cli/root.go index 4170b58..b44d82a 100644 --- a/packages/cli/internal/bootstrap/cli/root.go +++ b/packages/cli/internal/bootstrap/cli/root.go @@ -65,7 +65,7 @@ func newRootCommand() *cobra.Command { envcmd.Commands(envcmd.Dependencies{Service: deps.environments}), runcmd.Commands(deps.loaders, deps.runtime), servecmd.Commands(servecmd.Dependencies{ - Catalog: deps.catalog, Workspaces: deps.workspaces, + Catalog: deps.catalog, Workspaces: deps.workspaces, Creation: deps.creation, Registry: deps.registry, Manifest: deps.manifest, Environments: deps.environments, }), templatescmd.Commands(), diff --git a/packages/cli/internal/modules/creation/project.go b/packages/cli/internal/modules/creation/project.go index 4ea3934..7513dcc 100644 --- a/packages/cli/internal/modules/creation/project.go +++ b/packages/cli/internal/modules/creation/project.go @@ -164,7 +164,8 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput } for _, p := range manifest.Projects { if p.RelativeDir == newProject.RelativeDir || p.Name == in.Name { - return ProjectResult{}, i18n.Errorf("creation.project_registered", in.Name) + return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, + i18n.Tf("creation.project_registered", in.Name)) } } manifest.Projects = append(manifest.Projects, newProject) diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 4a9e146..89a2d1f 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -800,5 +800,6 @@ "dependencies.failed_summary": "%s dependency preparation failed (%s): %v", "workspace.node_member_pattern": "Project %q has unsupported workspace pattern %q. Use relative package paths or single-level globs.", "creation.composite_requires_pnpm": "Template %q contains multiple Node packages and requires a pnpm workspace; the current workspace uses %q.", - "creation.duplicate_package": "Package name %q is shared by %q and %q. Choose a different project name." + "creation.duplicate_package": "Package name %q is shared by %q and %q. Choose a different project name.", + "serve.project_payload_invalid": "Invalid project creation request: %v" } diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 989e1ad..53be2a3 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -800,5 +800,6 @@ "dependencies.failed_summary": "%s 的依赖准备失败(%s):%v", "workspace.node_member_pattern": "项目 %q 中的 workspace 路径模式 %q 不受支持。请使用相对包路径或单层通配符。", "creation.composite_requires_pnpm": "模板 %q 包含多个 Node 包,需要 pnpm 工作区;当前工作区使用 %q。", - "creation.duplicate_package": "包名 %q 同时用于 %q 和 %q,请选择不同的项目名。" + "creation.duplicate_package": "包名 %q 同时用于 %q 和 %q,请选择不同的项目名。", + "serve.project_payload_invalid": "新建项目请求无效:%v" } diff --git a/packages/cli/internal/transport/cobra/serve/cmd.go b/packages/cli/internal/transport/cobra/serve/cmd.go index 14e3a99..c398a2b 100644 --- a/packages/cli/internal/transport/cobra/serve/cmd.go +++ b/packages/cli/internal/transport/cobra/serve/cmd.go @@ -21,6 +21,7 @@ import ( workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" @@ -30,6 +31,7 @@ import ( type Dependencies struct { Catalog *catalog.Catalog Manifest *manifestapp.Service + Creation *creationmodule.Service Environments *environmentmodule.Service Workspaces *workspaceapp.Service Registry *workspaceapp.RegistryService @@ -73,6 +75,7 @@ func newServeCmd(deps Dependencies) *cobra.Command { Catalog: deps.Catalog, ManifestService: deps.Manifest, + CreationService: deps.Creation, EnvironmentService: deps.Environments, WorkspaceService: deps.Workspaces, RegistryService: deps.Registry, @@ -164,7 +167,11 @@ func maybeOpenBrowser(stderr io.Writer, res serve.Result, open bool) { // keep our own friendly stderr line below). browser.Stderr = io.Discard browser.Stdout = io.Discard - if err := browser.OpenURL(res.URL); err != nil { - fmt.Fprintf(stderr, i18n.T("serve.browser_failed")+"\n", err) - } + // Desktop openers can wait for the browser to close or retain its output + // pipes. Never let that wait block HTTP startup or context cancellation. + go func() { + if err := browser.OpenURL(res.URL); err != nil { + fmt.Fprintf(stderr, i18n.T("serve.browser_failed")+"\n", err) + } + }() } diff --git a/packages/cli/internal/transport/http/handlers_project_create.go b/packages/cli/internal/transport/http/handlers_project_create.go new file mode 100644 index 0000000..cde08b2 --- /dev/null +++ b/packages/cli/internal/transport/http/handlers_project_create.go @@ -0,0 +1,109 @@ +package serve + +import ( + "net/http" + "path/filepath" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +type projectTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Category template.Category `json:"category"` + Directory string `json:"directory"` + Toolchain template.Toolchain `json:"toolchain"` +} + +func handleProjectTemplates() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + registry, err := template.Fetch(r.Context(), "") + if err != nil { + writeServiceError(w, err) + return + } + templates := make([]projectTemplate, 0, len(registry.Templates)) + directories := map[template.Category]string{ + template.CategoryFrontend: "apps", template.CategoryBackend: "services", template.CategoryLibrary: "packages", + } + for _, entry := range registry.Templates { + templates = append(templates, projectTemplate{ + ID: entry.ID, Name: entry.DisplayName(), Description: entry.DisplayDescription(), + Category: entry.Category, Directory: directories[entry.Category], Toolchain: entry.Toolchain, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"templates": templates}) + } +} + +type createProjectRequest struct { + Name string `json:"name"` + TemplateID string `json:"templateId"` +} + +type createProjectResponse struct { + Name string `json:"name"` + RelativeDir string `json:"relativeDir"` + TemplateID string `json:"templateId"` + Warnings []string `json:"warnings,omitempty"` +} + +// The registry gateway resolves the selected Workspace; the request only +// supplies a project name and a built-in template ID, never filesystem paths. +func handleCreateProject(opts MuxOpts) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body createProjectRequest + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if err := decodeJSON(r, &body); err != nil { + writeBadPayload(w, i18n.Tf("serve.project_payload_invalid", err)) + return + } + body.Name, body.TemplateID = strings.TrimSpace(body.Name), strings.TrimSpace(body.TemplateID) + if !workspace.IsValidProjectName(body.Name) { + writeServiceError(w, cliErrors.New(cliErrors.INVALID_NAME, i18n.Tf("add.name_invalid", body.Name))) + return + } + if body.TemplateID == "" { + writeServiceError(w, cliErrors.New(cliErrors.TEMPLATE_REQUIRED, i18n.T("add.template_required"))) + return + } + registry, err := template.Fetch(r.Context(), "") + if err != nil { + writeServiceError(w, err) + return + } + var selected *template.Template + for i := range registry.Templates { + if registry.Templates[i].ID == body.TemplateID { + selected = ®istry.Templates[i] + break + } + } + if selected == nil { + writeServiceError(w, cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, i18n.Tf("add.template_missing", body.TemplateID))) + return + } + result, err := opts.CreationService.AddProject(r.Context(), opts.WorkspaceRoot, creationmodule.ProjectInput{ + Template: selected, Name: body.Name, + }) + if err != nil { + writeServiceError(w, err) + return + } + relativeDir, err := filepath.Rel(opts.WorkspaceRoot, result.Project.TargetPath) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, createProjectResponse{ + Name: result.Project.Name, RelativeDir: filepath.ToSlash(relativeDir), + TemplateID: result.Project.TemplateID, Warnings: result.Project.Warnings, + }) + } +} diff --git a/packages/cli/internal/transport/http/handlers_project_create_test.go b/packages/cli/internal/transport/http/handlers_project_create_test.go new file mode 100644 index 0000000..7d3ed0b --- /dev/null +++ b/packages/cli/internal/transport/http/handlers_project_create_test.go @@ -0,0 +1,163 @@ +package serve + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +func TestCreateProjectInSelectedWorkspace(t *testing.T) { + registry := newRegistryService(t) + launchRoot := seedRegistryWorkspace(t, "launch", "Launch", "") + selectedRoot := seedRegistryWorkspace(t, "selected", "Selected", "") + selected := observeRegistryWorkspace(t, registry, selectedRoot) + handler := newRegistryMux(t, launchRoot, registry) + before := snapshotRepositoryTree(t, launchRoot) + path := "/api/workspaces/" + selected.EntryID + "/projects" + response := registryRequest(t, handler, http.MethodPost, path, strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) + if response.Code != http.StatusCreated { + t.Fatalf("status = %d: %s", response.Code, response.Body.String()) + } + var created createProjectResponse + if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if created.Name != "web" || created.RelativeDir != "apps/web" || created.TemplateID != "react-spa" { + t.Fatalf("created = %#v", created) + } + if _, err := os.Stat(filepath.Join(selectedRoot, "apps/web/package.json")); err != nil { + t.Fatal(err) + } + manifest, err := workspacecore.ReadManifest(selectedRoot) + if err != nil { + t.Fatal(err) + } + if len(manifest.Projects) != 1 || manifest.Projects[0].Name != "web" { + t.Fatalf("manifest = %#v", manifest) + } + assertRepositoryUnchanged(t, launchRoot, before) + before = snapshotRepositoryTree(t, selectedRoot) + for _, templateID := range []string{"react-spa", "go-api"} { + response = registryRequest(t, handler, http.MethodPost, path, strings.NewReader(`{"name":"web","templateId":"`+templateID+`"}`)) + if response.Code != http.StatusConflict { + t.Fatalf("duplicate status = %d: %s", response.Code, response.Body.String()) + } + assertRepositoryUnchanged(t, selectedRoot, before) + } +} + +func TestCreateProjectRejectsInvalidRequestsWithoutWrites(t *testing.T) { + registry := newRegistryService(t) + root := seedRegistryWorkspace(t, "selected", "Selected", "") + selected := observeRegistryWorkspace(t, registry, root) + handler := newRegistryMux(t, root, registry) + before := snapshotRepositoryTree(t, root) + for _, test := range []struct { + body string + status int + }{ + {`{"name":"../escape","templateId":"react-spa"}`, 400}, + {`{"name":"web/escape","templateId":"react-spa"}`, 400}, + {`{"name":"","templateId":"react-spa"}`, 400}, + {`{"name":"web","templateId":""}`, 400}, + {`{"name":"web","templateId":"unknown"}`, 404}, + {`{"name":"web","templateId":"react-spa","root":"/tmp"}`, 400}, + {`{"name":"web","templateId":"react-spa"} {}`, 400}, + } { + response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(test.body)) + if response.Code != test.status { + t.Errorf("%s: status = %d: %s", test.body, response.Code, response.Body.String()) + } + assertRepositoryUnchanged(t, root, before) + } + request := httptest.NewRequest(http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) + request.Host = registryTestHost + request.Header.Set("Origin", "http://external.example") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("origin status = %d", response.Code) + } + assertRepositoryUnchanged(t, root, before) +} + +func TestCreateProjectRejectsUnknownAndConflictingWorkspace(t *testing.T) { + registry := newRegistryService(t) + root := seedRegistryWorkspace(t, "copied-id", "A", "") + other := seedRegistryWorkspace(t, "copied-id", "B", "") + selected := observeRegistryWorkspace(t, registry, root) + observeRegistryWorkspace(t, registry, other) + handler := newRegistryMux(t, root, registry) + before := snapshotRepositoryTree(t, root) + for entry, status := range map[string]int{selected.EntryID: 409, "unknown": 404} { + response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+entry+"/projects", strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) + if response.Code != status { + t.Fatalf("status = %d: %s", response.Code, response.Body.String()) + } + } + assertRepositoryUnchanged(t, root, before) +} + +func TestCreateProjectConcurrentDuplicate(t *testing.T) { + registry := newRegistryService(t) + root := seedRegistryWorkspace(t, "selected", "Selected", "") + selected := observeRegistryWorkspace(t, registry, root) + handler := newRegistryMux(t, root, registry) + statuses := make(chan int, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) + statuses <- response.Code + }() + } + wg.Wait() + first, second := <-statuses, <-statuses + if !((first == 201 && second == 409) || (first == 409 && second == 201)) { + t.Fatalf("statuses = %d, %d", first, second) + } + manifest, err := workspacecore.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + if len(manifest.Projects) != 1 { + t.Fatalf("projects = %#v", manifest.Projects) + } +} + +func TestProjectTemplatesLocalized(t *testing.T) { + original := i18n.Active() + t.Cleanup(func() { _ = i18n.Init(original) }) + for _, locale := range []string{"zh-CN", "en-US"} { + _ = i18n.Init(locale) + handler := newRegistryMux(t, "", nil) + response := registryRequest(t, handler, http.MethodGet, "/api/project-templates", nil) + if response.Code != http.StatusOK { + t.Fatal(response.Body.String()) + } + var result struct { + Templates []projectTemplate `json:"templates"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Templates) == 0 { + t.Fatal("no templates") + } + for _, entry := range result.Templates { + if entry.Directory == "" || entry.Name != i18n.T("template."+entry.ID+".name") { + t.Fatalf("template = %#v", entry) + } + } + } +} diff --git a/packages/cli/internal/transport/http/handlers_workspaces.go b/packages/cli/internal/transport/http/handlers_workspaces.go index 56ebb00..0c000f2 100644 --- a/packages/cli/internal/transport/http/handlers_workspaces.go +++ b/packages/cli/internal/transport/http/handlers_workspaces.go @@ -22,6 +22,8 @@ func registerWorkspacesRoutes(mux *http.ServeMux, opts MuxOpts) { handleResolvedWorkspaceRead(opts, handleGetWorkspaceOverview)) mux.HandleFunc("GET /workspaces/{entryId}/environment", handleResolvedWorkspaceRead(opts, handleGetWorkspaceEnvironment)) + mux.HandleFunc("POST /workspaces/{entryId}/projects", + handleResolvedWorkspace(opts, handleCreateProject)) mux.HandleFunc("GET /workspaces/{entryId}/projects/{name}", handleResolvedWorkspaceRead(opts, handleGetWorkspaceProject)) diff --git a/packages/cli/internal/transport/http/middleware.go b/packages/cli/internal/transport/http/middleware.go index 0b9d236..e9b99be 100644 --- a/packages/cli/internal/transport/http/middleware.go +++ b/packages/cli/internal/transport/http/middleware.go @@ -22,6 +22,7 @@ import ( manifestapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/manifest" workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" + creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" @@ -45,6 +46,8 @@ type MuxOpts struct { // ManifestService is the explicit repository-publication boundary. It only // accepts typed, revision-checked project setting patches. ManifestService *manifestapp.Service + // CreationService reuses the CLI template-to-project workflow. + CreationService *creationmodule.Service // EnvironmentService powers Infisical secret operations through the same // workspace/session/path resolution used by the CLI. EnvironmentService *environmentmodule.Service @@ -86,7 +89,15 @@ func BuildMux(opts MuxOpts) http.Handler { } opts.EnvironmentService = service } + if opts.CreationService == nil { + service, err := creationmodule.NewService(opts.EnvironmentService) + if err != nil { + panic(err) + } + opts.CreationService = service + } api := http.NewServeMux() + api.HandleFunc("GET /project-templates", handleProjectTemplates()) registerSessionRoutes(api) registerGlobalRoutes(api) registerCatalogRoutes(api, opts) diff --git a/packages/cli/internal/transport/http/responses.go b/packages/cli/internal/transport/http/responses.go index fd6aace..e5a9c64 100644 --- a/packages/cli/internal/transport/http/responses.go +++ b/packages/cli/internal/transport/http/responses.go @@ -77,6 +77,12 @@ func statusForCode(code string) int { return http.StatusNotFound case string(cliErrors.INFISICAL_FOLDER_NOT_FOUND), string(cliErrors.INFISICAL_PROJECT_NOT_FOUND): return http.StatusNotFound + case string(cliErrors.TEMPLATE_NOT_FOUND): + return http.StatusNotFound + case string(cliErrors.TARGET_EXISTS): + return http.StatusConflict + case string(cliErrors.INVALID_NAME), string(cliErrors.TEMPLATE_REQUIRED), string(cliErrors.SUBPROJECT_NAME_REQUIRED): + return http.StatusBadRequest case string(cliErrors.ENV_SET_OVERWRITE_REQUIRED): return http.StatusConflict case string(cliErrors.ENV_BACKEND_UNCHANGED): diff --git a/packages/cli/internal/transport/http/server.go b/packages/cli/internal/transport/http/server.go index a40fda9..1a768e8 100644 --- a/packages/cli/internal/transport/http/server.go +++ b/packages/cli/internal/transport/http/server.go @@ -18,6 +18,7 @@ import ( manifestapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/manifest" workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" + creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" @@ -44,6 +45,7 @@ type Opts struct { WorkspaceRoot string Catalog *catalog.Catalog ManifestService *manifestapp.Service + CreationService *creationmodule.Service EnvironmentService *environmentmodule.Service WorkspaceService *workspaceapp.Service RegistryService *workspaceapp.RegistryService @@ -122,6 +124,7 @@ func Run(ctx context.Context, opts Opts, ready func(Result)) error { Catalog: opts.Catalog, ManifestService: opts.ManifestService, + CreationService: opts.CreationService, EnvironmentService: opts.EnvironmentService, WorkspaceService: opts.WorkspaceService, RegistryService: opts.RegistryService, diff --git a/packages/cli/tests/e2e/serve_browser_linux_test.go b/packages/cli/tests/e2e/serve_browser_linux_test.go new file mode 100644 index 0000000..add23db --- /dev/null +++ b/packages/cli/tests/e2e/serve_browser_linux_test.go @@ -0,0 +1,100 @@ +//go:build linux + +package cli_test + +import ( + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestE2E_ServeWithBlockingBrowser(t *testing.T) { + for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { + t.Run(locale, func(t *testing.T) { + bin := binaryPath(t) + root := t.TempDir() + isolateHome(t, root) + t.Setenv("LC_ALL", locale) + // Model a desktop opener that keeps running while its browser is + // open. Never launch the developer's actual browser from a test. + opener := "#!/bin/sh\nprintf '%s' \"$1\" > browser-url\nwhile :; do sleep 1; done\n" + if err := os.WriteFile(filepath.Join(root, "xdg-open"), []byte(opener), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH")) + log, err := os.Create(filepath.Join(root, "serve.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + cmd := exec.Command(bin, "serve", "--port", "0", "-o", "text") + cmd.Dir = root + cmd.Stdout, cmd.Stderr = log, log + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + finished := false + defer func() { + // Also stop the fake opener, which deliberately outlives one. + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if !finished { + <-done + } + }() + + var url string + deadline := time.Now().Add(5 * time.Second) + for url == "" { + if raw, err := os.ReadFile(filepath.Join(root, "browser-url")); err == nil { + url = strings.TrimSpace(string(raw)) + } + if url != "" { + break + } + select { + case err := <-done: + finished = true + t.Fatalf("serve exited before opening browser: %v", err) + default: + } + if time.Now().After(deadline) { + t.Fatal("serve did not launch the browser") + } + time.Sleep(10 * time.Millisecond) + } + + client := &http.Client{Timeout: 2 * time.Second} + for _, path := range []string{"", "api/catalog"} { + res, err := client.Get(url + path) + if err != nil { + t.Fatalf("HTTP request blocked while browser was open: %v", err) + } + res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("GET %s returned %d, want 200", path, res.StatusCode) + } + } + client.CloseIdleConnections() + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + finished = true + if err != nil { + t.Fatalf("serve shutdown failed: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("browser prevented serve from shutting down") + } + }) + } +} From d3b08516532f4f831516392519a513787ab98a2a Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Mon, 28 Sep 2026 16:02:15 +0800 Subject: [PATCH 02/16] fix(tui): restore log scrolling and reflow on resize Handle wheel and page navigation in the selected log pane, retain each project's scroll position, and keep paused history anchored at capacity. Reflow retained ANSI output after terminal resizing, coalesce rapid size changes, and synchronize child PTY dimensions. Keep replay bounded and avoid sending terminal query replies twice. Update Chinese and English shortcuts and documentation. Validation: task check, taskrun race tests, and real PTY input/scroll/resize regressions pass. --- apps/docs/content/docs/en/dev.md | 20 ++ apps/docs/content/docs/zh/dev.md | 20 ++ .../internal/platform/i18n/locales/en-US.json | 12 +- .../internal/platform/i18n/locales/zh-CN.json | 12 +- .../platform/taskrun/pty_unix_test.go | 16 +- .../cli/internal/platform/taskrun/session.go | 64 ++++-- .../cli/internal/platform/taskrun/terminal.go | 149 ++++++++++++++ .../platform/taskrun/terminal_test.go | 133 +++++++++++++ packages/cli/internal/platform/taskrun/ui.go | 87 ++++++-- .../cli/internal/platform/taskrun/ui_test.go | 186 +++++++++++++++++- .../cli/tests/e2e/task_terminal_unix_test.go | 46 ++++- 11 files changed, 689 insertions(+), 56 deletions(-) create mode 100644 packages/cli/internal/platform/taskrun/terminal.go create mode 100644 packages/cli/internal/platform/taskrun/terminal_test.go diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index 7dfeb1d..ed962f1 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -39,6 +39,26 @@ one dev web one dev apps/web --dry-run ``` +## Scroll and resize the task interface + +On Unix, multiple tasks use the TUI in an interactive terminal. Use `one dev web --ui=tui` to open it for a single project. `one build --ui=tui` uses the same log controls. + +| Control | Action | +|---|---| +| `↑` / `↓` | Select a project | +| Mouse wheel over the log panel | Scroll the selected project's output | +| `PgUp` / `PgDn` | Scroll logs; in a full-screen child's input mode these keys go to that child | +| `Shift+PgUp` / `Shift+PgDn` | Scroll history, including while in input mode | +| `f` / `End` | Resume following output in navigation mode | +| `Shift+End` | Resume following output in either mode | +| `Enter` / `Ctrl+]` | Enter child input / return to navigation | +| `h` | Hide or show the project list | +| `?` | Show all shortcuts, including restart and stop | + +Each project keeps its own scroll position. New output leaves a paused view in place until those rows leave the retained history. Typing or pasting into a child returns to the live screen. Resizing the terminal reflows retained log output and updates every child's terminal dimensions. Full-screen children redraw their own layouts. + +History is bounded to 3,000 screen rows, with an 1 MiB replay buffer per project. Older output can be dropped when these limits are reached. + ## Common errors | code | fix | diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index 5005933..d5f7c97 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -43,6 +43,26 @@ one dev web one dev apps/web --dry-run ``` +## 日志滚动与窗口缩放 + +在 Unix 交互式终端中运行多个任务时会进入 TUI。单项目也可通过 `one dev web --ui=tui` 打开;`one build --ui=tui` 使用相同的日志操作。 + +| 操作 | 行为 | +|---|---| +| `↑` / `↓` | 选择项目 | +| 日志区域内的鼠标滚轮 | 滚动当前项目的输出 | +| `PgUp` / `PgDn` | 翻阅日志;全屏子程序的输入模式中交给子程序处理 | +| `Shift+PgUp` / `Shift+PgDn` | 翻阅历史,输入模式下也可使用 | +| `f` / `End` | 在导航模式恢复跟随输出 | +| `Shift+End` | 在导航或输入模式恢复跟随输出 | +| `Enter` / `Ctrl+]` | 进入子进程输入/返回导航 | +| `h` | 隐藏或显示项目列表 | +| `?` | 查看全部快捷键,包括重启和停止 | + +每个项目独立保存滚动位置。查看历史时,新输出不会推动当前视图,直到这些历史行超出保留上限。向子进程键入或粘贴内容会返回实时画面。调整终端尺寸会重新换行保留的日志,并同步所有子进程的终端尺寸;全屏子程序自行重绘界面。 + +每个项目最多显示 3000 行历史,另保留最多 1 MiB 的输出供重新换行使用。达到上限后会丢弃较早的内容。 + ## 错误恢复 | 错误码 | 处理 | diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 89a2d1f..d571061 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -684,13 +684,13 @@ "preset.library_length": "library segment payload must be exactly 2 chars", "preset.env_duplicate": "duplicate env segment", "preset.env_length": "env segment payload must be exactly 1 char", - "task.ui.history_limit": " · latest 3000 history lines", - "task.ui.history": " · history (f: follow)", - "task.ui.help": "↑/↓ Select project\nEnter Send keyboard input to project\nCtrl+] Return to navigation\nPgUp/PgDn Scroll history\nf Follow latest output\nh Hide/show project list\n/ Search projects\nEsc Clear search\nCtrl+C / q Stop all tasks", + "task.ui.history_limit": " · history limited to 3000 rows / 1 MiB", + "task.ui.history": " · history (Shift+End: follow)", + "task.ui.help": "↑/↓ Select project\nEnter Send keyboard input to project\nCtrl+] Return to navigation\nWheel / PgUp/PgDn Scroll logs\nShift+PgUp/PgDn Scroll while typing\nf / End / Shift+End Follow output\nh Hide/show project list\n/ Search projects\nEsc Clear search\nCtrl+C / q Stop all tasks", "task.ui.help_dev": "\nr Restart selected project\ns Stop selected project", - "task.ui.hint": "↑↓ select · Enter input · PgUp/PgDn scroll · / search · ? help · Ctrl+C stop", - "task.ui.hint_dev": "↑↓ select · Enter input · r restart · s stop · / search · ? help · Ctrl+C stop", - "task.ui.input": "INPUT → %s · Ctrl+] return · Ctrl+C interrupts this project", + "task.ui.hint": "↑↓ select · Wheel/PgUp/PgDn scroll · f follow · Enter input · ? help · Ctrl+C stop", + "task.ui.hint_dev": "↑↓ select · Wheel/PgUp/PgDn scroll · f follow · Enter input · r restart · ? help", + "task.ui.input": "INPUT → %s · Shift+PgUp/PgDn scroll · Ctrl+] return · Ctrl+C interrupts project", "task.ui.search": "Search: %s · Enter apply · Esc clear", "task.status.pending": "pending", "task.status.running": "running", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 53be2a3..594cced 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -684,13 +684,13 @@ "preset.library_length": "库段必须为 2 个字符", "preset.env_duplicate": "环境段重复", "preset.env_length": "环境段必须为 1 个字符", - "task.ui.history_limit": " · 保留最近 3000 行历史", - "task.ui.history": " · 历史记录(f:跟随)", - "task.ui.help": "↑/↓ 选择项目\nEnter 向项目发送键盘输入\nCtrl+] 返回导航\nPgUp/PgDn 滚动历史记录\nf 跟随最新输出\nh 隐藏或显示项目列表\n/ 搜索项目\nEsc 清除搜索\nCtrl+C / q 停止所有任务", + "task.ui.history_limit": " · 历史上限 3000 行 / 1 MiB", + "task.ui.history": " · 历史记录(Shift+End:跟随)", + "task.ui.help": "↑/↓ 选择项目\nEnter 向项目发送键盘输入\nCtrl+] 返回导航\n滚轮 / PgUp/PgDn 滚动日志\nShift+PgUp/PgDn 输入时滚动日志\nf / End / Shift+End 跟随输出\nh 隐藏或显示项目列表\n/ 搜索项目\nEsc 清除搜索\nCtrl+C / q 停止所有任务", "task.ui.help_dev": "\nr 重启选中项目\ns 停止选中项目", - "task.ui.hint": "↑↓ 选择 · Enter 输入 · PgUp/PgDn 滚动 · / 搜索 · ? 帮助 · Ctrl+C 停止", - "task.ui.hint_dev": "↑↓ 选择 · Enter 输入 · r 重启 · s 停止 · / 搜索 · ? 帮助 · Ctrl+C 停止", - "task.ui.input": "输入 → %s · Ctrl+] 返回 · Ctrl+C 中断当前项目", + "task.ui.hint": "↑↓ 选择 · 滚轮/PgUp/PgDn 滚动 · f 跟随 · Enter 输入 · ? 帮助 · Ctrl+C 停止", + "task.ui.hint_dev": "↑↓ 选择 · 滚轮/PgUp/PgDn 滚动 · f 跟随 · Enter 输入 · r 重启 · ? 帮助", + "task.ui.input": "输入 → %s · Shift+PgUp/PgDn 滚动 · Ctrl+] 返回 · Ctrl+C 中断项目", "task.ui.search": "搜索:%s · Enter 应用 · Esc 清除", "task.status.pending": "等待中", "task.status.running": "运行中", diff --git a/packages/cli/internal/platform/taskrun/pty_unix_test.go b/packages/cli/internal/platform/taskrun/pty_unix_test.go index eb6f608..b0eb052 100644 --- a/packages/cli/internal/platform/taskrun/pty_unix_test.go +++ b/packages/cli/internal/platform/taskrun/pty_unix_test.go @@ -39,7 +39,7 @@ func TestPTYResizeAndTerminalResponses(t *testing.T) { s := &Session{width: 80, height: 24} j := &job{} s.initTerminal(j) - defer func() { _ = j.terminal.InputPipe().(io.Closer).Close(); <-j.inputDone; _ = j.terminal.Close() }() + defer func() { _ = j.terminal.Close() }() if _, err := (jobOutput{s, j}).Write([]byte("\x1b[31mred\x1b[0m\rnew\x1b[K\n\x1b[6n")); err != nil { t.Fatal(err) } @@ -82,13 +82,21 @@ func TestPTYCancellationCleansDescendants(t *testing.T) { case <-time.After(4 * time.Second): t.Fatal("cancel hung") } - if syscall.Kill(pid, 0) != syscall.ESRCH { + // Waiting for the shell does not wait for its descendants to finish + // handling the group signal. Allow the kernel to complete their exit. + deadline = time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if syscall.Kill(pid, 0) == syscall.ESRCH { + return + } raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) end := strings.LastIndex(string(raw), ") ") - if end < 0 || !strings.HasPrefix(string(raw)[end+2:], "Z ") { - t.Fatalf("descendant %d alive", pid) + if end >= 0 && strings.HasPrefix(string(raw)[end+2:], "Z ") { + return } + time.Sleep(10 * time.Millisecond) } + t.Fatalf("descendant %d alive", pid) } func TestPTYResizeReachesChild(t *testing.T) { diff --git a/packages/cli/internal/platform/taskrun/session.go b/packages/cli/internal/platform/taskrun/session.go index d11572f..b7faa2d 100644 --- a/packages/cli/internal/platform/taskrun/session.go +++ b/packages/cli/internal/platform/taskrun/session.go @@ -12,7 +12,7 @@ import ( "syscall" "time" - vt "github.com/charmbracelet/x/vt" + uv "github.com/charmbracelet/ultraviolet" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" @@ -38,17 +38,16 @@ type Options struct { Run func(context.Context, Task, io.Writer) error } type job struct { - task Task - result Result - terminal *vt.Emulator - cancel context.CancelFunc - started time.Time - action string - attempt int - offset int - ioMu sync.Mutex - child *childProcess - inputDone chan struct{} + task Task + result Result + terminal *terminal + cancel context.CancelFunc + started time.Time + action string + attempt int + offset int + ioMu sync.Mutex + child *childProcess } type Session struct { mu sync.Mutex @@ -121,8 +120,6 @@ func Run(ctx context.Context, tasks []Task, opts Options) ([]Result, error) { if opts.Mode == TUI { s.printFailures() for _, j := range s.jobs { - _ = j.terminal.InputPipe().(io.Closer).Close() - <-j.inputDone _ = j.terminal.Close() } } @@ -170,10 +167,7 @@ func validateTasks(tasks []Task) error { return nil } func (s *Session) initTerminal(j *job) { - j.terminal = vt.NewEmulator(s.width, s.height) - j.terminal.SetScrollbackSize(3000) - j.inputDone = make(chan struct{}) - go func() { defer close(j.inputDone); _, _ = io.Copy(jobInput{j}, j.terminal) }() + j.terminal = newTerminal(s.width, s.height, jobInput{j}) } type jobInput struct{ j *job } @@ -198,10 +192,38 @@ type jobOutput struct { func (w jobOutput) Write(p []byte) (int, error) { w.s.mu.Lock() defer w.s.mu.Unlock() - before := w.j.terminal.ScrollbackLen() - n, err := w.j.terminal.Write(p) + t := w.j.terminal + before := t.ScrollbackLen() + // Scrollback rows keep their cell storage as the bounded history rotates. + // Anchor to a retained row so new output cannot move a paused viewport, + // including after the history buffer has reached its capacity. + var anchor *uv.Cell + anchorIndex := max(0, before-w.j.offset) if w.j.offset > 0 { - w.j.offset += max(0, w.j.terminal.ScrollbackLen()-before) + for ; anchorIndex < before; anchorIndex++ { + if line := t.Scrollback().Line(anchorIndex); len(line) > 0 { + anchor = &line[0] + break + } + } + } + n, err := t.Write(p) + if w.j.offset > 0 { + delta := max(0, t.ScrollbackLen()-before) + if anchor != nil { + found := false + for i, line := range t.Scrollback().Lines() { + if len(line) > 0 && &line[0] == anchor { + delta = t.ScrollbackLen() - before + anchorIndex - i + found = true + break + } + } + if !found { + delta = t.ScrollbackLen() + } + } + w.j.offset = min(t.ScrollbackLen(), w.j.offset+delta) } return n, err } diff --git a/packages/cli/internal/platform/taskrun/terminal.go b/packages/cli/internal/platform/taskrun/terminal.go new file mode 100644 index 0000000..ac29d9a --- /dev/null +++ b/packages/cli/internal/platform/taskrun/terminal.go @@ -0,0 +1,149 @@ +package taskrun + +import ( + "bytes" + "io" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/parser" + vt "github.com/charmbracelet/x/vt" +) + +const historyLines = 3000 +const replayBytes = 1024 * 1024 + +// terminal keeps the original byte stream so a resize can reflow output instead +// of irreversibly cropping the emulator's cells. The emulator still interprets +// colors, carriage returns, erase sequences and interactive terminal programs. +// Access is serialized by Session.mu; its input pump owns the pipe reader. +type terminal struct { + *vt.Emulator + output []byte + truncated bool + input io.Writer + inputDone chan struct{} + discarding *ansi.Parser + initialStyle uv.Style +} + +func newTerminal(width, height int, input io.Writer) *terminal { + t := &terminal{Emulator: vt.NewEmulator(width, height), input: input} + t.SetScrollbackSize(historyLines) + t.pumpInput() + return t +} + +func (t *terminal) pumpInput() { + e := t.Emulator + t.inputDone = make(chan struct{}) + done := t.inputDone + go func() { + defer close(done) + _, _ = io.Copy(t.input, e) + }() +} + +func (t *terminal) Write(p []byte) (int, error) { + n, err := t.Emulator.Write(p) + t.retain(p[:n], replayBytes) + return n, err +} + +func (t *terminal) retain(p []byte, limit int) { + // An oversized unfinished control sequence is discarded through its end, + // rather than retaining unlimited bytes or displaying its tail as text. + if t.discarding != nil { + for i, b := range p { + t.discarding.Advance(b) + if t.discarding.State() == parser.GroundState { + t.discarding = nil + p = p[i+1:] + break + } + } + if t.discarding != nil { + return + } + } + t.output = append(t.output, p...) + if len(t.output) <= limit { + return + } + // Drop at least half the journal to amortize compaction. Prefer a logical + // newline, and never split a UTF-8 character or an ANSI control sequence. + cut, fallback := 0, 0 + decoder := ansi.NewParser() + style, fallbackStyle := t.initialStyle, t.initialStyle + decoder.SetHandler(ansi.Handler{HandleCsi: func(cmd ansi.Cmd, params ansi.Params) { + if cmd == 'm' { + uv.ReadStyle(params, &style) + } + }}) + for i, b := range t.output { + decoder.Advance(b) + if i+1 >= len(t.output)-limit/2 && decoder.State() == parser.GroundState { + if fallback == 0 { + fallback = i + 1 + fallbackStyle = style + } + if b == '\n' { + cut = i + 1 + break + } + } + } + if cut == 0 { + cut = fallback + style = fallbackStyle + } + t.truncated = true + if cut == 0 { + t.output = nil + t.discarding = decoder + return + } + t.initialStyle = style + t.output = bytes.Clone(t.output[cut:]) +} + +func (t *terminal) Resize(width, height int) { + if width == t.Width() && height == t.Height() { + return + } + // Full-screen children redraw their own alternate screen after SIGWINCH. + // Replaying their old cursor-addressed output would overwrite that layout. + if t.IsAltScreen() { + t.Emulator.Resize(width, height) + return + } + e := vt.NewEmulator(width, height) + e.SetScrollbackSize(historyLines) + // Replaying queries must never send duplicate terminal replies to a child. + // io.Pipe preserves write boundaries: an empty write is a local fence after + // all replay replies have been consumed. No fence bytes reach the child. + drained := make(chan struct{}) + go func() { + defer close(drained) + buf := make([]byte, 4096) + for { + n, err := e.Read(buf) + if n == 0 || err != nil { + return + } + } + }() + _, _ = e.Write([]byte(t.initialStyle.String())) + _, _ = e.Write(t.output) + _, _ = e.InputPipe().Write(nil) + <-drained + _ = t.Close() + t.Emulator = e + t.pumpInput() +} + +func (t *terminal) Close() error { + _ = t.Emulator.InputPipe().(io.Closer).Close() + <-t.inputDone + return t.Emulator.Close() +} diff --git a/packages/cli/internal/platform/taskrun/terminal_test.go b/packages/cli/internal/platform/taskrun/terminal_test.go new file mode 100644 index 0000000..91d49d9 --- /dev/null +++ b/packages/cli/internal/platform/taskrun/terminal_test.go @@ -0,0 +1,133 @@ +package taskrun + +import ( + "bytes" + "io" + "strings" + "sync" + "testing" + "unicode/utf8" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +func TestTerminalResizeReflowsOutputWithoutLosingTextOrColors(t *testing.T) { + term := newTerminal(40, 5, io.Discard) + defer term.Close() + _, _ = term.Write([]byte("\x1b[31mabcdefghijklmnopqrstuvwxyz\x1b[0m\r\n中文日志\r\nlast")) + for _, size := range [][2]int{{12, 4}, {50, 10}, {8, 3}, {40, 5}} { + term.Resize(size[0], size[1]) + var lines []string + for _, line := range term.Scrollback().Lines() { + lines = append(lines, line.Render()) + } + lines = append(lines, strings.Split(term.Render(), "\n")...) + plain := strings.Builder{} + for _, line := range lines { + if ansi.StringWidth(line) > size[0] { + t.Fatalf("width %d: %q", size[0], line) + } + plain.WriteString(strings.TrimRight(ansi.Strip(line), " ")) + } + if got := plain.String(); got != "abcdefghijklmnopqrstuvwxyz中文日志last" { + t.Fatalf("size %v lost or duplicated output: %q", size, got) + } + if !strings.Contains(strings.Join(lines, ""), "\x1b[") { + t.Fatal("lost color") + } + } +} + +func TestTerminalResizePreservesPartialSequencesAndCarriageReturns(t *testing.T) { + term := newTerminal(30, 5, io.Discard) + defer term.Close() + _, _ = term.Write([]byte("old progress\rnew\x1b[K\r\n\x1b[3")) + term.Resize(20, 6) + _, _ = term.Write([]byte("1mRED\x1b[0m\r\n\xe4\xb8")) + term.Resize(24, 6) + _, _ = term.Write([]byte("\xad")) + if got := term.String(); strings.Contains(got, "old") || !strings.Contains(got, "new\nRED\n中") { + t.Fatalf("control or UTF-8 sequence lost: %q", got) + } +} + +type replyBuffer struct { + sync.Mutex + bytes.Buffer +} + +func (b *replyBuffer) Write(p []byte) (int, error) { + b.Lock() + defer b.Unlock() + return b.Buffer.Write(p) +} + +func TestTerminalResizeDoesNotReplayRepliesOrLoseChildInput(t *testing.T) { + replies := &replyBuffer{} + term := newTerminal(40, 6, replies) + _, _ = term.Write([]byte("hello\x1b[6n\x1b[c\x1b]11;?\x1b\\")) + term.Resize(20, 8) + term.Resize(60, 12) + term.SendKey(uv.KeyPressEvent{Code: 'x', Text: "x"}) + _ = term.Close() + got := replies.String() + if strings.Count(got, "R") != 1 || strings.Count(got, "c") != 1 || strings.Count(got, "rgb:") != 1 || !strings.HasSuffix(got, "x") { + t.Fatalf("duplicate replies or lost input: %q", got) + } +} + +func TestTerminalReplayHistoryIsBoundedAtSequenceBoundaries(t *testing.T) { + term := &terminal{} + for range 50 { + term.retain([]byte("\x1b[31m中文日志\x1b[0m\r\n"), 128) + } + if len(term.output) > 128 || !term.truncated || !utf8.Valid(term.output) || !bytes.HasPrefix(term.output, []byte("\x1b[31m")) { + t.Fatalf("invalid history: %q", term.output) + } + term.retain([]byte("\x1b]0;"+strings.Repeat("x", 300)), 128) + if len(term.output) > 128 || term.discarding == nil { + t.Fatal("unbounded partial control sequence") + } + term.retain([]byte("ignored\x1b\\visible\r\n"), 128) + if string(term.output) != "visible\r\n" || term.discarding != nil { + t.Fatalf("partial sequence leaked into output: %q", term.output) + } +} + +func TestAlternateScreenResizeKeepsChildLayout(t *testing.T) { + term := newTerminal(30, 5, io.Discard) + defer term.Close() + _, _ = term.Write([]byte("main\x1b[?1049h\x1b[2J\x1b[Hscreen")) + term.Resize(50, 10) + if !term.IsAltScreen() || term.Width() != 50 || !strings.Contains(term.String(), "screen") { + t.Fatalf("alternate screen corrupted: %q", term.String()) + } + _, _ = term.Write([]byte("\x1b[?1049l")) + term.Resize(20, 5) + if term.IsAltScreen() || !strings.Contains(term.String(), "main") { + t.Fatalf("main screen not restored: %q", term.String()) + } +} + +func BenchmarkTerminalResizeHistory(b *testing.B) { + term := newTerminal(100, 30, io.Discard) + defer term.Close() + for range 10000 { + _, _ = term.Write([]byte(strings.Repeat("long log output ", 12) + "\r\n")) + } + b.ResetTimer() + for i := range b.N { + term.Resize(70+(i%2)*60, 30) + } +} + +func TestTrimmedReplayKeepsStylesThatSpanLines(t *testing.T) { + term := newTerminal(20, 4, io.Discard) + defer term.Close() + term.retain([]byte("\x1b[31m"+strings.Repeat("red line\r\n", 20)), 80) + term.Resize(30, 6) + if term.CellAt(0, 0).Style.Fg == nil || !strings.Contains(term.String(), "red line") { + t.Fatal("trimming lost a color set before the retained suffix") + } +} diff --git a/packages/cli/internal/platform/taskrun/ui.go b/packages/cli/internal/platform/taskrun/ui.go index 7d18837..bc4dd85 100644 --- a/packages/cli/internal/platform/taskrun/ui.go +++ b/packages/cli/internal/platform/taskrun/ui.go @@ -15,11 +15,13 @@ import ( ) type tickMsg struct{} +type resizeMsg struct{ width, height int } type model struct { s *Session selected, width, height int input, help, searching, hideList bool query string + resizePending bool } func (s *Session) show() error { @@ -40,22 +42,61 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tick() } case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - m.resize() + if msg.Width > 0 && msg.Height > 0 { + m.width, m.height = msg.Width, msg.Height + m.resizePending = true + return m, tea.Tick(75*time.Millisecond, func(time.Time) tea.Msg { + return resizeMsg{msg.Width, msg.Height} + }) + } + case resizeMsg: + if m.resizePending && msg.width == m.width && msg.height == m.height { + m.resizePending = false + m.resize() + } + case tea.MouseWheelMsg: + if !m.help && msg.Y >= 2 && msg.Y < m.height-2 && msg.X >= m.logLeft() && msg.X < m.width { + switch msg.Button { + case tea.MouseWheelUp: + m.scroll(3) + case tea.MouseWheelDown: + m.scroll(-3) + } + } case tea.PasteMsg: if m.input { m.s.mu.Lock() + m.s.jobs[m.selected].offset = 0 m.s.jobs[m.selected].terminal.Paste(msg.Content) m.s.mu.Unlock() } case tea.KeyPressMsg: key := msg.String() + // Reserve explicit history shortcuts even while typing into a child. + // Full-screen children keep their unmodified PgUp/PgDn bindings. + if !m.searching && !m.help { + m.s.mu.Lock() + alt := m.s.jobs[m.selected].terminal != nil && m.s.jobs[m.selected].terminal.IsAltScreen() + m.s.mu.Unlock() + switch { + case key == "shift+pgup" || (key == "pgup" && (!m.input || !alt)): + m.scroll(max(1, (m.height-4)/2)) + return m, nil + case key == "shift+pgdown" || (key == "pgdown" && (!m.input || !alt)): + m.scroll(-max(1, (m.height-4)/2)) + return m, nil + case key == "shift+end": + m.follow() + return m, nil + } + } if m.input { if key == "ctrl+]" { m.input = false return m, nil } m.s.mu.Lock() + m.s.jobs[m.selected].offset = 0 m.s.jobs[m.selected].terminal.SendKey(uv.KeyPressEvent(msg)) m.s.mu.Unlock() return m, nil @@ -89,6 +130,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down", "j": m.move(1) case "enter", "i": + m.help = false m.s.mu.Lock() m.input = m.s.jobs[m.selected].result.Status == "running" m.s.jobs[m.selected].offset = 0 @@ -114,14 +156,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "h": m.hideList = !m.hideList m.resize() - case "pgup", "u": - m.scroll(max(1, m.height/2)) - case "pgdown", "d": - m.scroll(-max(1, m.height/2)) + case "u": + m.scroll(max(1, (m.height-4)/2)) + case "d": + m.scroll(-max(1, (m.height-4)/2)) case "end", "f": - m.s.mu.Lock() - m.s.jobs[m.selected].offset = 0 - m.s.mu.Unlock() + m.follow() } } return m, nil @@ -132,13 +172,20 @@ func (m model) listWidth() int { } return min(28, m.width/3) } +func (m model) logLeft() int { + if width := m.listWidth(); width > 0 { + return width + 3 + } + return 0 +} func (m model) resize() { - w, h := max(10, m.width-m.listWidth()-3), max(2, m.height-4) + w, h := max(1, m.width-m.logLeft()), max(1, m.height-4) m.s.mu.Lock() defer m.s.mu.Unlock() m.s.width, m.s.height = w, h for _, j := range m.s.jobs { j.terminal.Resize(w, h) + j.offset = min(j.offset, j.terminal.ScrollbackLen()) j.ioMu.Lock() if j.child != nil { _ = j.child.Resize(w, h) @@ -183,6 +230,12 @@ func (m model) scroll(delta int) { j.offset = max(0, min(j.terminal.ScrollbackLen(), j.offset+delta)) } +func (m model) follow() { + m.s.mu.Lock() + defer m.s.mu.Unlock() + m.s.jobs[m.selected].offset = 0 +} + var accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#ea580c")).Bold(true) var muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) @@ -213,18 +266,27 @@ func (m model) View() tea.View { if j.attempt > 1 { detail += i18n.Tf("task.ui.attempt", j.attempt) } - if j.terminal.ScrollbackLen() >= 3000 { + if j.terminal.ScrollbackLen() >= historyLines || j.terminal.truncated { detail += i18n.T("task.ui.history_limit") } if j.offset > 0 { detail += i18n.T("task.ui.history") } body := terminalView(j, panelHeight) + if m.resizePending { + // Keep the frame inside the new bounds while rapid resize events settle. + lines := strings.Split(body, "\n") + for i := range lines { + lines[i] = ansi.Truncate(lines[i], max(1, width-m.logLeft()), "") + } + body = strings.Join(lines, "\n") + } if m.help { body = i18n.T("task.ui.help") if s.opts.Development { body += i18n.T("task.ui.help_dev") } + body = ansi.Hardwrap(body, max(1, width-m.logLeft()), true) } lw := m.listWidth() if lw > 0 { @@ -267,7 +329,8 @@ func (m model) View() tea.View { } v := tea.NewView(header + "\n" + muted.Render(ansi.Truncate(detail, width, "…")) + "\n" + lipgloss.NewStyle().Height(panelHeight).MaxHeight(panelHeight).Render(body) + "\n" + muted.Render(ansi.Truncate(hint, width, "…"))) v.AltScreen = true - if m.input && j.offset == 0 { + v.MouseMode = tea.MouseModeCellMotion + if m.input && j.offset == 0 && !m.resizePending { pos := j.terminal.CursorPosition() v.Cursor = &tea.Cursor{Position: tea.Position{X: pos.X + max(0, lw) + func() int { if lw > 0 { diff --git a/packages/cli/internal/platform/taskrun/ui_test.go b/packages/cli/internal/platform/taskrun/ui_test.go index 784722c..1cf4087 100644 --- a/packages/cli/internal/platform/taskrun/ui_test.go +++ b/packages/cli/internal/platform/taskrun/ui_test.go @@ -1,8 +1,8 @@ package taskrun import ( + "fmt" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "io" "strings" "testing" "time" @@ -15,7 +15,7 @@ func TestTerminalHistoryIncludesCurrentScreenAndKeepsColors(t *testing.T) { s := &Session{width: 20, height: 3} j := &job{} s.initTerminal(j) - defer func() { _ = j.terminal.InputPipe().(io.Closer).Close(); <-j.inputDone; _ = j.terminal.Close() }() + defer func() { _ = j.terminal.Close() }() _, _ = j.terminal.Write([]byte("one\r\ntwo\r\n\x1b[31mthree\x1b[0m\r\nfour\r\nfive")) j.offset = 1 got := terminalView(j, 3) @@ -76,9 +76,187 @@ func TestLocalizedUIKeepsChildOutputAndTerminalWidth(t *testing.T) { if j.result.Status != "running" || terminalView(j, 20) != original { t.Fatal("rendering mutated protocol or log") } - _ = j.terminal.InputPipe().(io.Closer).Close() - <-j.inputDone _ = j.terminal.Close() } } } + +func logModel(t *testing.T) model { + t.Helper() + s := &Session{opts: Options{Title: "dev", Development: true}, width: 69, height: 8} + for _, name := range []string{"web", "api"} { + j := &job{task: Task{Name: name}, result: Result{Status: "running"}, started: time.Now()} + s.initTerminal(j) + s.jobs = append(s.jobs, j) + t.Cleanup(func() { _ = j.terminal.Close() }) + for i := range 40 { + _, _ = fmt.Fprintf(jobOutput{s, j}, "%s-%02d\r\n", name, i) + } + } + return model{s: s, width: 100, height: 12} +} + +func TestSelectedProjectScrollsWithMouseAndKeyboard(t *testing.T) { + m := logModel(t) + if m.View().MouseMode != tea.MouseModeCellMotion { + t.Fatal("mouse reporting disabled") + } + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(model) + next, _ = m.Update(tea.MouseWheelMsg{X: 50, Y: 4, Button: tea.MouseWheelUp}) + m = next.(model) + if m.selected != 1 || m.s.jobs[1].offset != 3 || m.s.jobs[0].offset != 0 { + t.Fatal("wheel did not scroll only the selected project") + } + before := terminalView(m.s.jobs[1], 8) + _, _ = (jobOutput{m.s, m.s.jobs[1]}).Write([]byte("new output\r\n")) + if got := terminalView(m.s.jobs[1], 8); visibleLog(got) != visibleLog(before) { + t.Fatalf("new logs moved a paused viewport (offset %d, history %d):\n%s\nwant:\n%s", m.s.jobs[1].offset, m.s.jobs[1].terminal.ScrollbackLen(), got, before) + } + for _, event := range []tea.MouseWheelMsg{{X: 2, Y: 4, Button: tea.MouseWheelUp}, {X: 50, Y: 0, Button: tea.MouseWheelUp}, {X: 50, Y: 11, Button: tea.MouseWheelUp}} { + offset := m.s.jobs[1].offset + next, _ = m.Update(event) + m = next.(model) + if m.s.jobs[1].offset != offset { + t.Fatal("wheel outside log panel changed history") + } + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + m = next.(model) + if m.selected != 0 || m.s.jobs[0].offset != 0 { + t.Fatal("project selection changed another project's scroll position") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnd}) + m = next.(model) + if m.s.jobs[1].offset != 0 { + t.Fatal("End did not resume live output") + } +} + +func TestInputModeCanBrowseHistoryAndThenResumeTyping(t *testing.T) { + m := logModel(t) + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) + m = next.(model) + if !m.input || m.s.jobs[0].offset == 0 || m.View().Cursor != nil { + t.Fatal("input mode cannot browse history safely") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnd, Mod: tea.ModShift}) + m = next.(model) + if m.s.jobs[0].offset != 0 || m.View().Cursor == nil { + t.Fatal("Shift+End did not restore the live input cursor") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp, Mod: tea.ModShift}) + m = next.(model) + next, _ = m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) + m = next.(model) + if m.s.jobs[0].offset != 0 || !m.input { + t.Fatal("typing did not restore live output") + } +} + +func TestPausedHistoryStaysAnchoredWhenHistoryIsFull(t *testing.T) { + m := logModel(t) + j := m.s.jobs[0] + for i := range historyLines + 20 { + _, _ = fmt.Fprintf(jobOutput{m.s, j}, "line-%04d\r\n", i) + } + m.scroll(12) + before := terminalView(j, 8) + _, _ = (jobOutput{m.s, j}).Write([]byte("new-1\r\nnew-2\r\nnew-3\r\n")) + if got := terminalView(j, 8); visibleLog(got) != visibleLog(before) { + t.Fatalf("history moved after reaching the cap:\n%s\nwant:\n%s", got, before) + } + m.scroll(historyLines) + _, _ = (jobOutput{m.s, j}).Write([]byte("evict oldest\r\n")) + if j.offset != historyLines { + t.Fatal("evicted history left an invalid scroll offset") + } +} + +func TestWindowAndSidebarResizeEveryProjectToTheActualLogArea(t *testing.T) { + m := logModel(t) + m.scroll(10) + for _, size := range []tea.WindowSizeMsg{{Width: 80, Height: 18}, {Width: 50, Height: 10}, {Width: 130, Height: 25}} { + next, _ := m.Update(size) + m = next.(model) + next, _ = m.Update(resizeMsg{m.width, m.height}) + m = next.(model) + for _, j := range m.s.jobs { + if j.terminal.Width() != m.width-m.logLeft() || j.terminal.Height() != m.height-4 { + t.Fatalf("%s was not resized: %dx%d", j.task.Name, j.terminal.Width(), j.terminal.Height()) + } + if j.offset < 0 || j.offset > j.terminal.ScrollbackLen() { + t.Fatal("resize left an invalid scroll offset") + } + } + for _, line := range strings.Split(m.View().Content, "\n") { + if ansi.StringWidth(line) > m.width { + t.Fatalf("resized output overflows: %q", line) + } + } + } + next, _ := m.Update(tea.KeyPressMsg{Code: 'h', Text: "h"}) + m = next.(model) + if m.s.width != m.width || !m.hideList { + t.Fatal("hidden sidebar still consumes log width") + } +} + +// History trims trailing empty cells; compare visible content across the +// boundary between scrollback and the current terminal screen. +func visibleLog(s string) string { + lines := strings.Split(ansi.Strip(s), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} + +func TestRapidResizeUsesOnlyTheLatestDimensions(t *testing.T) { + m := logModel(t) + for _, width := range []int{90, 50, 120} { + next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 16}) + m = next.(model) + for _, line := range strings.Split(m.View().Content, "\n") { + if ansi.StringWidth(line) > width { + t.Fatalf("pending resize overflow: %q", line) + } + } + } + next, _ := m.Update(resizeMsg{50, 16}) + m = next.(model) + if !m.resizePending || m.s.width != 69 { + t.Fatal("stale resize changed terminal geometry") + } + next, _ = m.Update(resizeMsg{120, 16}) + m = next.(model) + if m.resizePending || m.s.width != 89 { + t.Fatal("final resize was not applied") + } +} + +func TestFullScreenChildKeepsPageKeysAndExplicitHistoryShortcut(t *testing.T) { + replies := &replyBuffer{} + term := newTerminal(60, 8, replies) + _, _ = term.Write([]byte(strings.Repeat("line\r\n", 30) + "\x1b[?1049h")) + j := &job{terminal: term, result: Result{Status: "running"}} + m := model{s: &Session{jobs: []*job{j}}, width: 60, height: 12, input: true} + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) + m = next.(model) + if j.offset != 0 { + t.Fatal("unmodified PgUp was intercepted from a full-screen child") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp, Mod: tea.ModShift}) + m = next.(model) + if j.offset == 0 || !m.input { + t.Fatal("explicit history shortcut was not available") + } + _ = term.Close() + if got := replies.String(); got != "\x1b[5~" { + t.Fatalf("wrong keys forwarded to child: %q", got) + } +} diff --git a/packages/cli/tests/e2e/task_terminal_unix_test.go b/packages/cli/tests/e2e/task_terminal_unix_test.go index d039807..ed881ee 100644 --- a/packages/cli/tests/e2e/task_terminal_unix_test.go +++ b/packages/cli/tests/e2e/task_terminal_unix_test.go @@ -154,7 +154,18 @@ func TestE2E_SingleDevAndBuildExitWithIdleTerminalInput(t *testing.T) { func TestE2E_DevTUIInputResizeAndStop(t *testing.T) { root := devTerminalFixture(t, true) - buildWrite(t, root, "apps/web/dev.sh", "test -t 0 && test -t 1 || exit 91\necho run >> runs\necho WEB_READY\nwhile read value; do printf 'web:%s\\n' \"$value\"; done\n") + buildWrite(t, root, "apps/web/dev.sh", `test -t 0 && test -t 1 || exit 91 +echo run >> runs +i=0 +while [ "$i" -lt 60 ]; do + printf 'LOG_%02d original project output\n' "$i" + i=$((i + 1)) +done +echo WEB_READY +while read value; do + if [ "$value" = size ]; then stty size > terminal-size; else printf 'web:%s\n' "$value"; fi +done +`) buildWrite(t, root, "packages/lib/dev.sh", "echo LIB_READY\nsleep 60\n") tt := startTaskTerminal(t, root, "dev", "web", "lib", "--keep-going", "-o", "text") waitForTTYOutput(t, tt.out, "WEB_READY", 8*time.Second) @@ -163,10 +174,39 @@ func TestE2E_DevTUIInputResizeAndStop(t *testing.T) { } _, _ = tt.pty.Write([]byte("\r")) waitForTTYOutput(t, tt.out, "INPUT", 5*time.Second) + // Real SGR mouse input must browse history even after entering child input. + if !strings.Contains(tt.out.String(), "\x1b[?1006h") { + t.Fatal("TUI did not enable mouse reporting") + } + _, _ = tt.pty.Write([]byte("\x1b[<64;55;5M")) + waitForTTYOutput(t, tt.out, "Shift+End", 5*time.Second) _, _ = tt.pty.Write([]byte("hello\r")) waitForTTYOutput(t, tt.out, "web:hello", 5*time.Second) - _ = pty.Setsize(tt.pty, &pty.Winsize{Rows: 24, Cols: 80}) - _, _ = tt.pty.Write([]byte{29}) // Ctrl+] leaves input mode. + assertChildSize := func(cols, rows, logCols int) { + t.Helper() + if err := pty.Setsize(tt.pty, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}); err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("%d %d", rows-4, logCols) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + _, _ = tt.pty.Write([]byte("size\r")) + raw, _ := os.ReadFile(filepath.Join(root, "apps/web/terminal-size")) + if strings.TrimSpace(string(raw)) == want { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("child did not resize to %s", want) + } + assertChildSize(80, 24, 51) + assertChildSize(60, 18, 60) // Automatically hidden sidebar uses the full width. + assertChildSize(110, 30, 79) + _, _ = tt.pty.Write([]byte{29}) // Ctrl+] leaves input mode. + _, _ = tt.pty.Write([]byte("h\r")) // Hide the list, then return to child input. + assertChildSize(110, 30, 110) + _, _ = tt.pty.Write([]byte{29}) + _, _ = tt.pty.Write([]byte("r")) deadline := time.Now().Add(5 * time.Second) restarted := false From bea9ad952db3ef5344fcb02f5569aeaa3b5aa9b6 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Mon, 28 Sep 2026 17:56:48 +0800 Subject: [PATCH 03/16] feat(cli)!: unify workspace tasks with mise Use one exec for arbitrary commands and one run for named tasks. Delegate workspace scheduling and artifact caching to mise, retain pnpm and Task for project commands, and remove CI generation and bundled skills. Update templates, Dashboard task details, bilingual documentation, repository workflows, and regression coverage. --- .githooks/pre-commit | 10 +- .github/workflows/ci.yml | 193 ++--- .github/workflows/cli.yml | 38 +- .github/workflows/docs.yml | 25 +- .gitignore | 10 +- AGENTS.md | 2 +- ARCHITECTURE.md | 90 +-- CHANGELOG.md | 16 +- CONTRIBUTING.md | 91 +-- README.md | 53 +- Taskfile.yml | 407 ---------- .../project-settings/forms/GeneralForm.tsx | 48 ++ apps/dashboard/src/locales/en-US.json | 12 +- apps/dashboard/src/locales/zh-CN.json | 12 +- apps/dashboard/src/pages/Overview.test.tsx | 32 + apps/dashboard/src/types/api.ts | 10 + .../content/blog/en/agent-skill-context.md | 2 +- .../content/blog/zh/agent-skill-context.md | 2 +- apps/docs/content/docs/en/add.md | 1 - apps/docs/content/docs/en/ai-native.md | 2 - apps/docs/content/docs/en/build.md | 42 +- apps/docs/content/docs/en/ci.md | 70 -- apps/docs/content/docs/en/cli-overview.md | 6 +- apps/docs/content/docs/en/create.md | 5 +- apps/docs/content/docs/en/dev.md | 8 +- apps/docs/content/docs/en/env-vars.md | 2 +- apps/docs/content/docs/en/error-codes.md | 25 - apps/docs/content/docs/en/exec.md | 86 +++ apps/docs/content/docs/en/installation.md | 10 +- apps/docs/content/docs/en/login.md | 2 +- apps/docs/content/docs/en/manifest.md | 2 +- apps/docs/content/docs/en/meta.json | 3 +- apps/docs/content/docs/en/run.md | 126 +-- apps/docs/content/docs/en/skills.md | 49 -- apps/docs/content/docs/zh/add.md | 3 +- apps/docs/content/docs/zh/ai-native.md | 2 - apps/docs/content/docs/zh/build.md | 42 +- apps/docs/content/docs/zh/ci.md | 65 -- apps/docs/content/docs/zh/cli-overview.md | 6 +- apps/docs/content/docs/zh/create.md | 5 +- apps/docs/content/docs/zh/dev.md | 8 +- apps/docs/content/docs/zh/env-vars.md | 2 +- apps/docs/content/docs/zh/error-codes.md | 56 +- apps/docs/content/docs/zh/exec.md | 97 +++ apps/docs/content/docs/zh/hk.md | 2 +- apps/docs/content/docs/zh/installation.md | 12 +- apps/docs/content/docs/zh/login.md | 2 +- apps/docs/content/docs/zh/manifest.md | 2 +- apps/docs/content/docs/zh/meta.json | 3 +- apps/docs/content/docs/zh/run.md | 131 ++-- apps/docs/content/docs/zh/skills.md | 49 -- apps/docs/content/tutorials/en/dev-local.mdx | 8 +- .../content/tutorials/en/env-multi-env.mdx | 2 +- apps/docs/content/tutorials/en/env-vars.mdx | 4 +- .../content/tutorials/en/infisical-login.mdx | 2 +- apps/docs/content/tutorials/zh/dev-local.mdx | 8 +- .../content/tutorials/zh/env-multi-env.mdx | 2 +- apps/docs/content/tutorials/zh/env-vars.mdx | 4 +- .../content/tutorials/zh/infisical-login.mdx | 2 +- .../plans/2026-09-14-mise-runtime-adoption.md | 2 + .../2026-09-26-mise-runtime-bootstrap.md | 2 + .../plans/2026-09-28-dev-build-terminal-ui.md | 2 + .../2026-09-28-workspace-task-unification.md | 723 ++++++++++++++++++ mise.toml | 141 ++++ package.json | 2 +- packages/cli/Taskfile.yml | 66 ++ .../adapters/ci/githubactions/provider.go | 64 -- .../ci/githubactions/workflowinput.go | 23 - .../internal/adapters/env/dotenv/loader.go | 9 +- .../cli/internal/adapters/toolchain/go.go | 61 -- .../cli/internal/adapters/toolchain/node.go | 40 +- .../internal/adapters/toolchain/runtime.go | 119 --- .../adapters/toolchain/runtime_test.go | 193 ----- .../cli/internal/application/ci/operations.go | 256 ------- .../cli/internal/application/ci/result.go | 33 - .../cli/internal/application/ci/service.go | 210 ----- .../internal/application/ci/service_test.go | 142 ---- .../application/execution/operation.go | 55 +- .../workspace/project_build_settings_test.go | 7 +- .../application/workspace/project_settings.go | 2 + .../internal/application/workspace/service.go | 9 +- .../internal/application/workspace/tasks.go | 28 + .../internal/bootstrap/cli/dependencies.go | 16 +- packages/cli/internal/bootstrap/cli/root.go | 12 +- .../cli/internal/bootstrap/cli/root_test.go | 6 +- .../core/workspace/package_manager.go | 45 +- .../core/workspace/package_manager_test.go | 17 +- .../cli/internal/core/workspace/projects.go | 19 +- packages/cli/internal/core/workspace/tasks.go | 230 ++++++ packages/cli/internal/modules/build/plan.go | 195 ----- .../cli/internal/modules/build/plan_test.go | 214 ------ .../cli/internal/modules/build/service.go | 145 ---- .../internal/modules/build/service_test.go | 107 --- .../internal/modules/creation/artifacts.go | 7 - .../internal/modules/creation/languages.go | 64 +- .../modules/creation/languages_test.go | 21 +- .../modules/creation/package_format_test.go | 2 +- .../cli/internal/modules/creation/preset.go | 6 +- .../cli/internal/modules/creation/project.go | 17 +- .../modules/creation/workspace_content.go | 14 +- .../modules/creation/workspace_files.go | 4 +- .../internal/modules/dependencies/service.go | 87 +-- .../modules/dependencies/service_test.go | 6 +- .../modules/development/process/ops.go | 13 +- .../modules/development/process/ops_test.go | 2 +- packages/cli/internal/modules/hooks/config.go | 10 +- .../cli/internal/modules/hooks/hooks_test.go | 4 +- .../cli/internal/modules/miseconfig/cache.go | 138 ++++ .../modules/miseconfig/cache_inputs.go | 116 +++ .../internal/modules/miseconfig/cache_test.go | 111 +++ .../cli/internal/modules/miseconfig/config.go | 152 ++-- .../modules/miseconfig/config_test.go | 10 +- .../cli/internal/modules/skills/agents.go | 177 ----- .../cli/internal/modules/skills/install.go | 93 --- .../internal/modules/skills/skills_test.go | 197 ----- .../cli/internal/modules/tasks/context.go | 270 +++++++ .../cli/internal/modules/tasks/development.go | 74 ++ packages/cli/internal/modules/tasks/files.go | 131 ++++ packages/cli/internal/modules/tasks/native.go | 153 ++++ packages/cli/internal/modules/tasks/plan.go | 410 ++++++++++ .../cli/internal/modules/tasks/plan_test.go | 181 +++++ .../cli/internal/modules/tasks/projection.go | 28 + .../cli/internal/modules/tasks/service.go | 149 ++++ .../cli/internal/platform/errors/codes.go | 59 +- .../internal/platform/errors/codes_test.go | 4 - .../internal/platform/i18n/locales/en-US.json | 201 ++--- .../internal/platform/i18n/locales/zh-CN.json | 201 ++--- .../cli/internal/platform/process/forward.go | 6 +- .../cli/internal/platform/process/lookpath.go | 38 + .../internal/platform/process/stop_unix.go | 46 ++ .../internal/platform/process/stop_windows.go | 12 + packages/cli/internal/ports/secrets/loader.go | 14 +- packages/cli/internal/ports/secrets/merge.go | 3 +- .../cli/internal/resources/bundled/bundled.go | 24 +- .../resources/bundled/bundled_test.go | 30 +- .../cli/internal/transport/cobra/build/cmd.go | 83 -- .../internal/transport/cobra/ci/actions.go | 72 -- .../cli/internal/transport/cobra/ci/cmd.go | 122 --- .../cli/internal/transport/cobra/ci/render.go | 82 -- .../internal/transport/cobra/ci/selection.go | 28 - .../internal/transport/cobra/create/create.go | 4 - .../internal/transport/cobra/create/preset.go | 2 - .../internal/transport/cobra/create/result.go | 4 - .../cli/internal/transport/cobra/dev/cmd.go | 23 +- .../cli/internal/transport/cobra/dev/task.go | 27 + .../internal/transport/cobra/env/global.go | 2 +- .../cli/internal/transport/cobra/exec/cmd.go | 419 ++++++++++ .../cobra/{run => exec}/cmd_windows_test.go | 2 +- .../transport/cobra/{run => exec}/exec.go | 10 +- .../transport/cobra/{run => exec}/global.go | 8 +- .../cobra/{run => exec}/global_test.go | 2 +- .../cli/internal/transport/cobra/run/cmd.go | 504 +++--------- .../internal/transport/cobra/skills/cmd.go | 94 --- .../cli/internal/transport/http/middleware.go | 2 +- packages/cli/pkg/ci/provider.go | 157 ---- packages/cli/pkg/ci/provider_test.go | 29 - packages/cli/pkg/toolchain/adapter.go | 1 - packages/cli/pkg/toolchain/registry_test.go | 2 - packages/cli/pkg/toolchain/types.go | 11 - .../testdata/reference/create-default.json | 1 - .../reference/create-preset-fullstack.json | 1 - .../cli/testdata/reference/help/build.txt | 24 +- packages/cli/testdata/reference/help/ci.txt | 23 - .../testdata/reference/help/ci_disable.txt | 20 - .../cli/testdata/reference/help/ci_enable.txt | 21 - .../cli/testdata/reference/help/ci_sync.txt | 19 - packages/cli/testdata/reference/help/dev.txt | 1 + .../cli/testdata/reference/help/env_get.txt | 2 +- packages/cli/testdata/reference/help/exec.txt | 23 + packages/cli/testdata/reference/help/mise.txt | 2 +- packages/cli/testdata/reference/help/root.txt | 2 + packages/cli/testdata/reference/help/run.txt | 31 +- .../cli/testdata/reference/help/skills.txt | 12 - .../reference/help/skills_install.txt | 26 - packages/cli/tests/e2e/build_test.go | 101 ++- packages/cli/tests/e2e/build_unix_test.go | 4 +- .../cli/tests/e2e/dependencies_unix_test.go | 34 + packages/cli/tests/e2e/e2e_helpers_test.go | 6 +- packages/cli/tests/e2e/mise_runtime_test.go | 41 +- .../cli/tests/e2e/mise_runtime_unix_test.go | 19 +- packages/cli/tests/e2e/skills_test.go | 94 --- .../cli/tests/e2e/snapshot_e2e_ci_test.go | 183 ----- .../cli/tests/e2e/snapshot_e2e_create_test.go | 6 +- .../cli/tests/e2e/snapshot_e2e_run_test.go | 30 +- packages/cli/tests/e2e/snapshot_e2e_test.go | 4 +- .../tests/e2e/snapshot_e2e_tty_unix_test.go | 78 -- .../cli/tests/e2e/snapshot_e2e_ux_test.go | 10 +- packages/cli/tests/e2e/snapshot_help_test.go | 2 +- .../cli/tests/e2e/task_terminal_unix_test.go | 18 +- packages/cli/tests/e2e/tasks_cache_test.go | 195 +++++ packages/cli/tools/gen-error-codes/main.go | 5 - packages/cli/tools/repo-checks/main.go | 2 +- packages/cli/tools/sync-resources/main.go | 7 +- .../cli/tools/sync-resources/main_test.go | 4 +- .../cli/tools/verify-cli-references/main.go | 1 - packages/cli/tools/verify-help/main.go | 1 + packages/kernel/Taskfile.yml | 14 + packages/templates/expo-mobile/package.json | 3 +- packages/templates/go-api/Taskfile.yml | 13 +- packages/templates/go-lib/Taskfile.yml | 6 +- .../templates/nestjs-api/tsconfig.build.json | 1 + skills/one-cli/SKILL.md | 47 -- 202 files changed, 5259 insertions(+), 6059 deletions(-) delete mode 100644 Taskfile.yml delete mode 100644 apps/docs/content/docs/en/ci.md create mode 100644 apps/docs/content/docs/en/exec.md delete mode 100644 apps/docs/content/docs/en/skills.md delete mode 100644 apps/docs/content/docs/zh/ci.md create mode 100644 apps/docs/content/docs/zh/exec.md delete mode 100644 apps/docs/content/docs/zh/skills.md create mode 100644 docs/plans/2026-09-28-workspace-task-unification.md create mode 100644 mise.toml create mode 100644 packages/cli/Taskfile.yml delete mode 100644 packages/cli/internal/adapters/ci/githubactions/provider.go delete mode 100644 packages/cli/internal/adapters/ci/githubactions/workflowinput.go delete mode 100644 packages/cli/internal/adapters/toolchain/runtime.go delete mode 100644 packages/cli/internal/adapters/toolchain/runtime_test.go delete mode 100644 packages/cli/internal/application/ci/operations.go delete mode 100644 packages/cli/internal/application/ci/result.go delete mode 100644 packages/cli/internal/application/ci/service.go delete mode 100644 packages/cli/internal/application/ci/service_test.go create mode 100644 packages/cli/internal/application/workspace/tasks.go create mode 100644 packages/cli/internal/core/workspace/tasks.go delete mode 100644 packages/cli/internal/modules/build/plan.go delete mode 100644 packages/cli/internal/modules/build/plan_test.go delete mode 100644 packages/cli/internal/modules/build/service.go delete mode 100644 packages/cli/internal/modules/build/service_test.go create mode 100644 packages/cli/internal/modules/miseconfig/cache.go create mode 100644 packages/cli/internal/modules/miseconfig/cache_inputs.go create mode 100644 packages/cli/internal/modules/miseconfig/cache_test.go delete mode 100644 packages/cli/internal/modules/skills/agents.go delete mode 100644 packages/cli/internal/modules/skills/install.go delete mode 100644 packages/cli/internal/modules/skills/skills_test.go create mode 100644 packages/cli/internal/modules/tasks/context.go create mode 100644 packages/cli/internal/modules/tasks/development.go create mode 100644 packages/cli/internal/modules/tasks/files.go create mode 100644 packages/cli/internal/modules/tasks/native.go create mode 100644 packages/cli/internal/modules/tasks/plan.go create mode 100644 packages/cli/internal/modules/tasks/plan_test.go create mode 100644 packages/cli/internal/modules/tasks/projection.go create mode 100644 packages/cli/internal/modules/tasks/service.go create mode 100644 packages/cli/internal/platform/process/lookpath.go create mode 100644 packages/cli/internal/platform/process/stop_unix.go create mode 100644 packages/cli/internal/platform/process/stop_windows.go delete mode 100644 packages/cli/internal/transport/cobra/build/cmd.go delete mode 100644 packages/cli/internal/transport/cobra/ci/actions.go delete mode 100644 packages/cli/internal/transport/cobra/ci/cmd.go delete mode 100644 packages/cli/internal/transport/cobra/ci/render.go delete mode 100644 packages/cli/internal/transport/cobra/ci/selection.go create mode 100644 packages/cli/internal/transport/cobra/dev/task.go create mode 100644 packages/cli/internal/transport/cobra/exec/cmd.go rename packages/cli/internal/transport/cobra/{run => exec}/cmd_windows_test.go (98%) rename packages/cli/internal/transport/cobra/{run => exec}/exec.go (85%) rename packages/cli/internal/transport/cobra/{run => exec}/global.go (96%) rename packages/cli/internal/transport/cobra/{run => exec}/global_test.go (99%) delete mode 100644 packages/cli/internal/transport/cobra/skills/cmd.go delete mode 100644 packages/cli/pkg/ci/provider.go delete mode 100644 packages/cli/pkg/ci/provider_test.go delete mode 100644 packages/cli/testdata/reference/help/ci.txt delete mode 100644 packages/cli/testdata/reference/help/ci_disable.txt delete mode 100644 packages/cli/testdata/reference/help/ci_enable.txt delete mode 100644 packages/cli/testdata/reference/help/ci_sync.txt create mode 100644 packages/cli/testdata/reference/help/exec.txt delete mode 100644 packages/cli/testdata/reference/help/skills.txt delete mode 100644 packages/cli/testdata/reference/help/skills_install.txt delete mode 100644 packages/cli/tests/e2e/skills_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_ci_test.go create mode 100644 packages/cli/tests/e2e/tasks_cache_test.go create mode 100644 packages/kernel/Taskfile.yml delete mode 100644 skills/one-cli/SKILL.md diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b4b6c57..dd46d58 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,7 +7,7 @@ cd "$repo_root" unstaged=$(git diff --name-only --ignore-submodules --) untracked=$(git ls-files --others --exclude-standard) if [ -n "$unstaged" ] || [ -n "$untracked" ]; then - printf '%s\n' "pre-commit: the working tree must match the staged snapshot before running task check." >&2 + printf '%s\n' "pre-commit: the working tree must match the staged snapshot before running mise run check." >&2 if [ -n "$unstaged" ]; then printf '%s\n%s\n' "Unstaged files:" "$unstaged" >&2 fi @@ -18,10 +18,10 @@ if [ -n "$unstaged" ] || [ -n "$untracked" ]; then exit 1 fi -if ! command -v task >/dev/null 2>&1; then - printf '%s\n' "pre-commit: go-task is required; install it or run 'git commit --no-verify' to bypass intentionally." >&2 +if ! command -v mise >/dev/null 2>&1; then + printf '%s\n' "pre-commit: mise is required; install it or run 'git commit --no-verify' to bypass intentionally." >&2 exit 1 fi -printf '%s\n' "pre-commit: running the PR gate (task check)" -exec task check +printf '%s\n' "pre-commit: running the PR gate (mise run check)" +exec mise run check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc6209..703167e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,12 @@ # PR-time test gate with native Linux, Windows, macOS, and Go race coverage. # -# Two PR jobs call the same subtasks used by the local `task check` entrypoint: -# - lint: `task check:static` covers documentation drift, Go vet/gofmt, and +# Two PR jobs call the same subtasks used by the local `mise run check` entrypoint: +# - lint: `mise run check:static` covers documentation drift, Go vet/gofmt, and # Dashboard lint/format. The whole bundled # tree (registry/_templates/_web) is gitignored and regenerated -# via `task sync-bundled` + `task sync-web`, both of which run as deps -# of `task vet` — so vet implicitly bootstraps the bundle here. -# - test: `task check:test` builds bin/one then runs Go + Dashboard tests. +# via `mise run sync-bundled` + `mise run sync-web`, both of which run as deps +# of `mise run vet` — so vet implicitly bootstraps the bundle here. +# - test: `mise run check:test` builds bin/one then runs Go + Dashboard tests. # Build is required because tests/e2e snapshot tests skip when # bin/one is missing. # Native Windows, macOS, and Go race jobs also run on every PR. The master @@ -36,7 +36,8 @@ permissions: contents: read env: - # GitHub-hosted runners are outside China; the default Taskfile proxy + MISE_TASK_CACHE_DIR: ${{ github.workspace }}/.cache/mise-task-artifacts + # GitHub-hosted runners are outside China; the default Go proxy # (goproxy.cn) is slower/flakier there and has caused CI download failures. GOPROXY: https://proxy.golang.org,direct GOTOOLCHAIN: local @@ -47,34 +48,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 + - name: Set up mise + uses: jdx/mise-action@v4 with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - # The root packageManager pin drives version selection for every workspace app. - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + version: 2026.9.7 + experimental: true - - name: Set up Task - uses: go-task/setup-task@v2 + - name: Restore task artifacts + uses: actions/cache@v4 with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -101,41 +86,26 @@ jobs: fi - name: Run static gate - # Calls the same static subtask as local `task check`. - run: task check:static + # Calls the same static subtask as local `mise run check`. + run: mise run check:static test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 + - name: Set up mise + uses: jdx/mise-action@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + version: 2026.9.7 + experimental: true - - name: Set up Task - uses: go-task/setup-task@v2 + - name: Restore task artifacts + uses: actions/cache@v4 with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -162,41 +132,26 @@ jobs: fi - name: Run test gate - # Calls the same test subtask as local `task check`. - run: task check:test + # Calls the same test subtask as local `mise run check`. + run: mise run check:test test-windows: runs-on: windows-latest steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Set up mise + uses: jdx/mise-action@v4 with: - run_install: false + version: 2026.9.7 + experimental: true - - name: Set up Node - uses: actions/setup-node@v6 + - name: Restore task artifacts + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Set up Task - uses: go-task/setup-task@v2 - with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules shell: pwsh @@ -208,7 +163,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Run native Windows PR gate - run: task check + run: mise run check test-race: # Catch Go data races before merging, alongside the other required jobs. @@ -216,33 +171,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Set up mise + uses: jdx/mise-action@v4 with: - run_install: false + version: 2026.9.7 + experimental: true - - name: Set up Node - uses: actions/setup-node@v6 + - name: Restore task artifacts + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Set up Task - uses: go-task/setup-task@v2 - with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -270,7 +210,7 @@ jobs: - name: Run Go race tests # test:go builds the CLI for E2E coverage; Dashboard tests run in test. - run: task test:go + run: mise run test:go test-macos: # Run before merging so macOS path and process regressions block PRs. @@ -278,33 +218,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 + - name: Set up mise + uses: jdx/mise-action@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + version: 2026.9.7 + experimental: true - - name: Set up Task - uses: go-task/setup-task@v2 + - name: Restore task artifacts + uses: actions/cache@v4 with: - version: 3.53.1 + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules run: | @@ -331,4 +256,4 @@ jobs: fi - name: Run test gate - run: task check:test + run: mise run check:test diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 2013879..340a512 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -25,6 +25,7 @@ permissions: contents: write env: + MISE_TASK_CACHE_DIR: ${{ github.workspace }}/.cache/mise-task-artifacts GOPROXY: https://proxy.golang.org,direct GOTOOLCHAIN: local @@ -253,37 +254,20 @@ jobs: RELEASE_SHA: ${{ steps.release.outputs.sha }} run: git checkout --detach "$RELEASE_SHA" - - name: Set up Go + - name: Set up mise if: steps.release.outputs.already_published != 'true' - uses: actions/setup-go@v7 + uses: jdx/mise-action@v4 with: - go-version-file: packages/cli/go.mod - cache: true - cache-dependency-path: | - go.work - go.work.sum - packages/cli/go.sum - packages/kernel/go.mod - - - name: Set up Task - if: steps.release.outputs.already_published != 'true' - uses: go-task/setup-task@v2 - with: - version: 3.53.1 - - - name: Set up pnpm - if: steps.release.outputs.already_published != 'true' - uses: pnpm/action-setup@v4 - with: - run_install: false + version: 2026.9.7 + experimental: true - - name: Set up Node + - name: Restore task artifacts if: steps.release.outputs.already_published != 'true' - uses: actions/setup-node@v6 + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml', 'pnpm-lock.yaml', 'packages/cli/go.sum') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- - name: Download Go modules without mutating the workspace if: steps.release.outputs.already_published != 'true' @@ -313,7 +297,7 @@ jobs: - name: Run complete release gate if: steps.release.outputs.already_published != 'true' - run: task pre-push + run: mise run pre-push - name: Verify release tree is clean if: steps.release.outputs.already_published != 'true' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a841faa..8365c7f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,6 +10,9 @@ on: - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "mise.toml" + - "packages/templates/registry.json" + - ".github/workflows/docs.yml" push: branches: [master] paths: @@ -17,6 +20,9 @@ on: - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "mise.toml" + - "packages/templates/registry.json" + - ".github/workflows/docs.yml" workflow_dispatch: permissions: @@ -29,20 +35,11 @@ jobs: - name: Check out uses: actions/checkout@v7 - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Set up mise + uses: jdx/mise-action@v4 with: - run_install: false - - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install docs deps - run: pnpm --filter one-cli-docs install --frozen-lockfile + version: 2026.9.7 + experimental: true - name: Build docs site - run: pnpm docs:build + run: mise run docs:build diff --git a/.gitignore b/.gitignore index 78fb23f..2999a51 100644 --- a/.gitignore +++ b/.gitignore @@ -24,15 +24,14 @@ go-dist/ # seo-cli local state .seo/ -# Bundled go:embed sources — regenerated by `task sync-bundled` (cheap -# cp from packages/templates/) and `task sync-web` +# Bundled go:embed sources — regenerated by `mise run sync-bundled` (cheap +# cp from packages/templates/) and `mise run sync-web` # (pnpm install + vite build of apps/dashboard/). Both run as deps of -# `task vet` / `test` / `build`, so the normal Taskfile flow always +# `mise run vet` / `test` / `build`, so the normal mise flow always # repopulates them. Fresh clone followed by `go build` directly will # fail until one of those tasks has run at least once. packages/cli/internal/resources/bundled/registry.json packages/cli/internal/resources/bundled/_templates/ -packages/cli/internal/resources/bundled/_skills/ packages/cli/internal/resources/bundled/_web/ # go-task @@ -53,3 +52,6 @@ bin-go/ .seo/patches/ # Legacy mise build resources from older checkouts; no longer used or embedded. /packages/cli/internal/adapters/runtime/mise/assets/ + +# mise task artifacts +.cache/mise-task-artifacts/ diff --git a/AGENTS.md b/AGENTS.md index 182cdfe..e6bb8fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,4 +32,4 @@ - 两份语言字典的键必须完整对应、译文非空、格式占位符兼容。新增翻译键时确认实际使用处能够解析。 - 根据改动覆盖两种语言的帮助、交互、错误或结果输出,并检查语言切换后没有旧语言残留。涉及持久化偏好的测试使用临时 HOME/config,避免修改开发者设置。 - 修改提示组件或 TUI 时,检查中文终端显示宽度、快捷键提示和文字对比度,并确认子进程原始输出仍被保留。 -- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `task check`。 +- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `mise run check`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c5a03e3..3aaeaa8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,28 +102,27 @@ packages/cli/internal/ cli/ constructs adapters and injects all dependencies core/ backend/ backend identity, capabilities, profile type, and form policy - container/ OCI image workflow inputs, results, and registry value profile/ machine profile model and persistence primitives template/ template registry and selection model workspace/ manifest and workspace model ports/ - deploy/ deployment provider contract secrets/ command environment loader contract application/ - ci/ configure/ deployment/ execution/ workspace/ + configure/ execution/ workspace/ adapters/ - ci/ container/ deploy/ env/ shared/ toolchain/ + env/ shared/ toolchain/ runtime/ modules/ - container/ compiled Docker/OCI workflow and manifest publication creation/ Template-to-Workspace/Project materialisation development/ local development process orchestration environment/ dotenv/Infisical workflows and workspace setup preset/ pure preset encoding, parsing, and resolution - skills/ bundled one-cli skill installation into agent user directories + tasks/ finite task plans and environment snapshots + miseconfig/ additive task and tool configuration + hooks/ staged-file checks and Git integration platform/ errors/ helpui/ i18n/ output/ preferences/ process/ prompt/ updatecheck/ resources/ - bundled/ embedded templates, registry, one-cli skill, and Dashboard + bundled/ embedded templates, registry, and Dashboard transport/ cobra/ one directory per command family http/ local Dashboard API @@ -173,7 +172,7 @@ Rules: 9. Cleanup is LIFO, idempotent, and best-effort. It covers local temporary resources; it does not pretend that an external cloud deployment is a reversible transaction. -10. Command, deploy, secrets, and CI provider sets are constructed explicitly. +10. Command, runtime, toolchain, and secrets provider sets are constructed explicitly. Provider packages do not register themselves through `init()`. 11. `internal/architecture/dependencies_test.go` enforces these boundaries for production Go files, including leaf-layer and transport/adapter rules. @@ -224,7 +223,7 @@ execution.Workspace root + manifest snapshot + project lookup `--> infer Project from the command working directory ``` -Deploy, container, dev, CI, configure, run, add, and environment commands use +Dev, configure, run, exec, add, and environment commands use this same boundary. Helpers receive the snapshot rather than a root string that would let them rediscover the workspace. A workflow that intentionally writes `one.manifest.json` must call `Workspace.Reload` before relying on the new @@ -234,15 +233,12 @@ outside a workspace renders help instead of producing a workspace error. Creation is one Template-driven compiled workflow: - `modules/creation.Service` is the single mutation boundary shared by ordinary - `one create`, `one create --preset`, `one add`, and first-deploy artifact - configuration; + `one create`, `one create --preset`, and `one add`; - workspace target revalidation, skeleton generation, Backend selection, environment preparation, Template rendering, manifest publication, project artifact generation and best-effort Git initialization stay behind that boundary; -- its private `syncProject` step owns container artifacts, the persisted dev - command, deploy configuration, and environment safety rules in dependency - order; +- its private `syncProject` step owns the persisted dev command and environment safety rules; - `modules/preset` is a pure plan format: it owns only preset codes, parsing, canonical encoding, registry resolution, and flag-conflict validation; - there is no top-level `modules/scaffold`: workspace-file generation is an @@ -254,7 +250,7 @@ Creation is one Template-driven compiled workflow: Dashboard Workspace reads and machine-local Profile selections enter through `application/workspace.Service`. The service owns Overview construction, -Backend validation, Project lookup, Template/deployment compatibility, and +Backend validation, Project lookup, Template compatibility, and Profile-binding policy, but has no manifest-publication capability. `one.manifest.json` is a read-only fact source for that projection service: the Project projection exposes its values, a SHA-256 revision, and resolved @@ -305,53 +301,15 @@ clients never submit an arbitrary filesystem root. Existing singular `/api/workspace/*` routes remain pinned to the launch Workspace for wire compatibility. -CI is an application workflow with a public provider compatibility seam: - -- `application/ci.Service` owns workspace/project selection, provider - validation, workflow path and enabled-state detection, enable/sync/disable - execution, result construction, and delete-confirmation enforcement; -- `PlanDisable` exposes only the enabled count needed for a prompt and carries - its workspace snapshot privately into `Disable`; `Disable` rechecks the - current workflow files so a stale plan cannot bypass confirmation; -- `pkg/ci.Provider` remains the stable out-of-tree rendering contract, while - bootstrap constructs the instance registry used by the service; -- Cobra owns positional/flag conflict parsing, confirmation prompts, and TTY - rendering. Workflow files remain the CI state; the manifest is not mutated. - -Deployment is the reference deep module for this flow: - -- `application/deployment` owns configured-target discovery, project/backend - target planning, template compatibility, environment validation and - overrides, profile resolution, secret injection ordering, pre-deploy build - ordering, provider dispatch, and result publication; -- target discovery and compatibility helpers stay package-private; transports - enter through the `PlanTargets` and `Execute` workflow surfaces instead of - assembling deployment policy from low-level functions; -- `ports/deploy.Provider` and `ports/deploy.Builder` are the narrow execution - seams used by the workflow; -- deploy adapters own vendor commands and the local project build process; -- Cobra consumes the application's ready/setup/choose-project plan and owns - only flag/argument parsing, interactive project/backend/profile choices, - progress presentation, and dry-run/result rendering. - -This boundary is intentional: adding another deployment transport must be able -to reuse the same workflow without importing Cobra, while adding another deploy -backend must not modify the workflow. - -Container is a vertical compiled module rather than a provider ecosystem: - -- `docker`, `dockerhub`, `ghcr`, and `acr` remain four user-visible Backend - identities because they have different profile and registry-host policy; -- all four use one Docker/OCI execution implementation, so - `modules/container.Service` validates Catalog capabilities, resolves registry - profiles, invokes the Docker adapter, and publishes image/build state back to - the manifest; -- `core/container` contains only transport-neutral values and result envelopes; -- Cobra owns project selection, version prompts, flags, dry-run display, and - result rendering; -- there is deliberately no `container.Provider` or `ProviderRegistry`. A real - execution seam should be introduced only when a second independent engine - exists, not when another registry profile is added. +Finite workspace tasks are owned by `modules/tasks` and `modules/miseconfig`: + +- `core/workspace.DiscoverTasks` reads pnpm scripts and Go Taskfiles. Commands stay in their native project files. +- `modules/miseconfig` generates additive mise fragments, tool pins, local dependency edges, and explicit cache declarations. User overrides stay in `mise.toml`. +- `modules/tasks` validates selection and dependencies, freezes each project's environment before cache lookup, then invokes mise once for the finite graph. +- Hidden task adapters consume the same temporary context for cache fingerprints and execution. Context values never enter generated configuration or result envelopes. +- mise owns task concurrency and artifact storage. One reports overall success/failure and leaves unsupported per-task event state unknown. +- `one build` and `one run build` share this implementation. `one exec` handles arbitrary commands, while development keeps the existing terminal supervisor after finite prerequisite builds. +- GitHub Actions files stay repository-owned and call the ordinary `ci` aggregate. Hooks and preset remain supported. Environment is a vertical deep module because its two built-in backends are compiled implementation components rather than independently distributed @@ -444,9 +402,9 @@ view layer. `src/architecture/dependencies.test.ts` enforces these rules and rejects local TypeScript barrel entrypoints so imports stay directly analyzable. -Repository verification has one public contract: `task check` (also exposed as +Repository verification has one public contract: `mise run check` (also exposed as root `pnpm check`). It composes `check:static` and `check:test`; CI runs those -same two subtasks in parallel. `task pre-push` adds Go race detection without +same two subtasks in parallel. `mise run pre-push` adds Go race detection without creating a separate definition of the PR gate. Node dependency resolution is likewise repository-owned: root @@ -460,14 +418,14 @@ remain independently installable after leaving this repository. Internal migration must preserve: -- current command names, flags, help contracts, and exit behavior; +- command exit behavior, except for explicitly approved command removals and renames; - `one.manifest.json` v1; - `~/.config/one/config.json` and `credentials.json` v1; - the legacy profile resolution order, extended ahead of it by optional Project+Environment and Workspace+Environment bindings from the additive machine-local `profile-bindings.json` v1 store; - structured success envelopes and `one-cli/error/v1` error codes; -- existing public packages under `packages/cli/pkg`. +- remaining public toolchain packages under `packages/cli/pkg`; the CI rendering contract has been removed. New HTTP endpoints may be added. Existing read and Profile-management payloads remain compatible; historical Dashboard routes that wrote a repository are a diff --git a/CHANGELOG.md b/CHANGELOG.md index 1348eaf..3be4ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed — 精简 One CLI skill 与显式安装 - -- `one skills install` 安装单个内置 `one-cli` skill:遵循 One Workspace Convention,并通过当前 CLI 的 help 查询命令。日常 CLI 升级无需同步更新 skill。 -- 支持交互选择、`--agent` / `-a` 和 `--yes`;非交互安装到检测到的 Agent,无目标时提示显式选择。支持 JSON / YAML / text 输出及离线安装。 -- skill 直接复制到 Agent 的用户级目录。重复安装替换 `one-cli` 目录;兼容旧符号链接,不修改旧共享 store 或其他 skills。 -- 普通与 preset 创建均生成简短的根 `AGENTS.md`,要求使用 skill,缺失时运行 `one skills install`。创建项目不自动安装 skill,`one add` 保留已有指引。 -- 旧的 `one-migrate`、详细 playbook、共享 store 安装器和公开 Go 包 `packages/cli/pkg/agentskills` 保持移除。旧机器上的其他安装不自动清理。 -- `create` / `add` 输出不恢复旧 `skills` / `ai_guides` 字段;模板仍跳过独立的 `CLAUDE.md`、`AGENTS.md` 和 `.one` 元数据。 +### Changed — 工作区任务统一到 mise / Workspace tasks use mise + +- `one exec` 执行任意命令,`one run` 执行具名任务;`one build` 复用同一任务服务。Node 使用 pnpm,Go 使用 Task,工作区依赖与实验性产物缓存由 mise 调度。 +- `one exec` runs arbitrary commands; `one run` executes named tasks, and `one build` shares the same service. pnpm and Task own project commands; mise owns workspace scheduling and experimental artifact caching. +- 项目环境在缓存查找前冻结并参与指纹;自定义构建需要显式缓存规则。开发进程保留现有终端管理,上游构建使用 mise。 +- Project environments are frozen before cache lookup and included in fingerprints. Custom builds need explicit cache declarations. Development keeps its terminal supervisor and uses mise for prerequisite builds. +- 删除 `one ci`、工作流生成器和现有 skills 功能;保留仓库 Actions、hooks 和 preset。仓库贡献入口改为 `mise run check`。 +- Removed `one ci`, workflow generation, and the current skills feature. Repository Actions, hooks, and presets remain. The contributor gate is now `mise run check`. ### Removed (BREAKING — `one serve` session token) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9c2a3c..d85072c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,42 +5,43 @@ ## 一次性环境 ```bash -brew install go go-task node # macOS;Linux 用 apt / dnf 类比 -npm i -g pnpm@10.14.0 # 与根 package.json 的 packageManager 一致 +brew install mise # macOS;其他系统先安装 mise git clone https://github.com/1cli-team/one-cli cd one-cli -task install # 打包 Dashboard + CLI,再创建当前平台的本地启动器 +mise trust +mise install # 安装 mise.toml 固定的工具版本 +mise run install # 打包 Dashboard + CLI,再创建本地启动器 one --version # 验证装好 ``` -工具链:**Go 1.26+**、**pnpm 10.14.0**。Node 推荐使用 **24.x(至少 24.15.0)**,也支持 22.x(至少 22.22.2)或 26+;Dashboard 测试依赖的 jsdom 不再支持 Node 20。`go-task`(不是 GNU make)是任务总线,跨平台一致。 +工具版本以根 `mise.toml` 为准:Go 1.27.1、Node 24.15.0、pnpm 10.14.0、Task 3.53.1。工作区由 mise 调度,Go 子项目使用 Task,Node 项目使用 pnpm。 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— -> registry / templates / dashboard dist 都由 `task sync-bundled` + -> `task sync-web` 按需重建。这些任务作为 `task vet` / `test` / `build` 的依赖自动运行; -> mise 不再是内嵌资源,构建与普通测试不需要下载 mise。 -> 第一次 `task install` 会触发 `pnpm install + vite build`,~30s;之后 -> task fingerprint 命中,几乎零成本。如果你直接跑 `go build` 而不走 Taskfile, +> registry / templates / dashboard dist 都由 `mise run sync-bundled` + +> `mise run sync-web` 按需重建。这些任务作为 `mise run vet` / `test` / `build` 的依赖自动运行; +> mise 程序不嵌入 One 发布文件;贡献者先安装 mise,再使用根任务。 +> 第一次 `mise run install` 会准备依赖并构建 Dashboard;之后输入未变时复用 Dashboard 产物缓存。 +> 如果你直接跑 `go build` 而不走 mise, > 会看到 `pattern all:_templates: no matching files found` 这种报错——跑一次 -> `task sync-bundled && task sync-web` 就好。 +> `mise run sync-bundled && mise run sync-web` 就好。 ## 日常开发 -所有命令都通过 Taskfile: +跨项目命令通过根 mise.toml 调度,Go 子项目使用自己的 Taskfile: ```bash -task --list # 看可用任务(这是真源) -pnpm install # 从根目录安装所有 Node workspace 依赖 -task dev # 同时启动 Go Dashboard API + Vite UI -task check # 在当前操作系统运行 monorepo 验证入口 -pnpm check # 根目录快捷入口,等价于 task check -task hooks:install # 为当前 checkout 启用提交前 PR gate -task build # 编译到 packages/cli/bin/one -task test # 全套 Go 测试 + race detector -task vet # go vet -task fmt # gofmt -task install # 打包 + 本地启动器(开发用;install-local 仍是兼容别名) -task pre-push # 推前必跑(含上面所有 + verify-docs) +mise tasks ls # 查看可用任务 +mise run install-deps # 安装根 Node workspace 的锁定依赖 +mise run dev # 同时启动 Go Dashboard API + Vite UI +mise run check # 在当前操作系统运行 monorepo 验证入口 +pnpm check # 根目录快捷入口,等价于 mise run check +mise run hooks:install # 为当前 checkout 启用提交前 PR gate +mise run build # 编译到 packages/cli/bin/one +mise run test # Go race 测试 + Dashboard 测试 +mise run vet # go vet +mise run fmt # gofmt +mise run install # 打包 + 本地启动器 +mise run pre-push # 推前必跑(含上面所有 + verify-docs) ``` `build` / `test` / `vet` 都隐式依赖 `sync-bundled` + `sync-web`,所以你不用 @@ -53,18 +54,18 @@ task pre-push # 推前必跑(含上面所有 + verify-docs) 2. 改代码 + 测试 3. 提交:commit 消息走 [conventional commits](https://www.conventionalcommits.org/) (`feat:`、`fix:`、`chore:`、`docs:`、`test:`、`refactor:` 等)。仓库的 - pre-commit hook 会自动运行与 PR CI 相同的 `task check`;如果当前 checkout 尚未 - 启用 hook,先运行 `task hooks:install`。hook 会拒绝混合已暂存、未暂存或未跟踪的 + pre-commit hook 会自动运行与 PR CI 相同的 `mise run check`;如果当前 checkout 尚未 + 启用 hook,先运行 `mise run hooks:install`。hook 会拒绝混合已暂存、未暂存或未跟踪的 文件,确保本地检查的内容与即将提交、随后由 CI 检查的快照一致 -4. **必跑** `task pre-push` 全绿(包含 Go race detector) +4. **必跑** `mise run pre-push` 全绿(包含 Go race detector) 5. 推送 + 开 PR -PR CI 在 Linux 上并行执行 `task check:static`、`task check:test` 与 -`task test:go`(Go race detector),同时在 Windows 上执行 `task check`、 -macOS 上执行 `task check:test`。master 的保护规则要求 `lint`、`test`、 +PR CI 在 Linux 上并行执行 `mise run check:static`、`mise run check:test` 与 +`mise run test:go`(Go race detector),同时在 Windows 上执行 `mise run check`、 +macOS 上执行 `mise run check:test`。master 的保护规则要求 `lint`、`test`、 `test-windows`、`test-macos`、`test-race` 五项检查全部通过才能合并。 -本地 `task check` 和 `task pre-push` 只验证当前操作系统,不能代替其他平台的 -CI;`task pre-push` 额外运行 Go race detector。远端五项检查会在 PR、master +本地 `mise run check` 和 `mise run pre-push` 只验证当前操作系统,不能代替其他平台的 +CI;`mise run pre-push` 额外运行 Go race detector。远端五项检查会在 PR、master 推送和手动触发的工作流中运行。 ## 改不同部分的注意事项 @@ -72,7 +73,7 @@ CI;`task pre-push` 额外运行 Go race detector。远端五项检查会在 PR ### 改 Go 代码(`packages/cli/internal/` / `packages/cli/pkg/`) - 公开 API(`packages/cli/pkg/`)改动要考虑 semver;详见 [CLAUDE.md 的 Public API stability](./CLAUDE.md) -- 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;测试会强制对应;改完跑 `task gen-error-codes` 刷新文档 +- 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;测试会强制对应;改完跑 `mise run gen-error-codes` 刷新文档 ### mise 运行时 @@ -86,18 +87,18 @@ One 发布文件不包含 mise 程序或压缩包。实际运行时优先使用 ### 改 templates(`packages/templates//`) -- 模板会被 `go:embed` 进二进制(`task sync-bundled` 是同步入口,自动跑) +- 模板会被 `go:embed` 进二进制(`mise run sync-bundled` 是同步入口,自动跑) - 加新模板:在 `packages/templates/registry.json` 登记 + 加 `packages/templates//` 目录 ### 改 dashboard(`apps/dashboard/`,`one serve` 的 UI) - React + Vite,pnpm 管理 -- 前后端联调:在仓库根目录运行 `task dev`,打开 `http://localhost:5173/` -- `task dev` 的 Workspace/Project 数据来自仓库内固定 fixture;Profile 增删改查仍会 +- 前后端联调:在仓库根目录运行 `mise run dev`,打开 `http://localhost:5173/` +- `mise run dev` 的 Workspace/Project 数据来自仓库内固定 fixture;Profile 增删改查仍会 操作本机真实的 One 配置,Profile binding 也会真实写入但只关联 fixture Workspace - 本地开发:先在仓库根目录运行 `pnpm install`,再运行 `pnpm --filter one-serve-web dev` -- 静态检查:`task check:dashboard`;架构护栏和交互测试包含在 `task check:test` -- 改完后 `task vet` / `test` / `build` 会自动跑 `sync-web`(pnpm install + vite build) +- 静态检查:`mise run check:dashboard`;架构护栏和交互测试包含在 `mise run check:test` +- 改完后 `mise run vet` / `test` / `build` 会自动跑 `sync-web`(pnpm install + vite build) 并刷 `packages/cli/internal/resources/bundled/_web/` ### 改文档站(`apps/docs/`) @@ -105,23 +106,23 @@ One 发布文件不包含 mise 程序或压缩包。实际运行时优先使用 - 文档站是 Next.js + Fumadocs SSG - 本地预览:先在仓库根目录运行 `pnpm install`,再运行 `pnpm docs:dev` - 线上部署:Vercel 项目 Root Directory 指向 `apps/docs`,Output Directory 用 `dist`,域名绑定 `1cli.dev` -- `apps/docs/content/docs/reference/error-codes.md` **不要手工编辑**——跑 `task gen-error-codes` 重生成 +- `apps/docs/content/docs/reference/error-codes.md` **不要手工编辑**——跑 `mise run gen-error-codes` 重生成 - 新增页面要更新对应目录的 `meta.json`(sidebar 顺序) ### 改 install.sh(`apps/docs/public/install.sh`) -- 改完跑 `task test` —— `packages/cli/tests/e2e/install_sh_test.go` 会做静态检查(语法、必要 sentinels、wrap-in-main 不变量) +- 改完跑 `mise run test` —— `packages/cli/tests/e2e/install_sh_test.go` 会做静态检查(语法、必要 sentinels、wrap-in-main 不变量) ## 测试约定 ```bash -task test # 默认全套 +mise run test # 默认全套 (cd packages/cli && go test ./internal/foo) # 单个包 (cd packages/cli && go test -run TestX ./...) # 单个 test (cd packages/cli && UPDATE_SNAPSHOTS=1 go test ./tests/e2e) # 重生成 e2e snapshot fixtures ``` -E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依赖 `packages/cli/bin/one` 存在 —— 跑 `task build` 之后再跑。 +E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依赖 `packages/cli/bin/one` 存在 —— 跑 `mise run build` 之后再跑。 ## 发布流程 @@ -129,7 +130,7 @@ E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依 1. 在 `master` 上运行工作流,选择 `patch`(默认)、`minor` 或 `major`。 2. 工作流从最高稳定 tag 自动计算下一版本,并把结果作为 `RELEASE_VERSION`;若最高 tag 位于 `master` 且尚未完成发布,则优先续跑该版本。 -3. 工作流执行完整 `task pre-push` 和 GoReleaser no-publish 预构建;验证通过后创建计算出的 tag,生成 5 个平台归档及 `checksums.txt`。 +3. 工作流执行完整 `mise run pre-push` 和 GoReleaser no-publish 预构建;验证通过后创建计算出的 tag,生成 5 个平台归档及 `checksums.txt`。 4. 只有 asset 集合完整时,GitHub Release 才会从 draft 转为公开发布。 不需要为了发布修改或提交任何版本文件,也不要手工推 tag。若发布在 tag 或 draft 创建后失败,修复问题后重新运行;工作流会自动识别安全的未完成 tag 并复用 draft。已经完整发布的版本不会被重复发布。 @@ -156,7 +157,7 @@ packages/cli/ # Go module(module path 含 /packages/cli 后 internal/ # 业务逻辑 pkg/ # 公开 Go API(semver 保护) internal/resources/bundled/ # go:embed 镜像,目录整个 gitignore, - # 由 task sync-bundled + sync-web 重建 + # 由 mise run sync-bundled + sync-web 重建 testdata/ # Go 测试 fixtures tools/ # 内部生成器 / 校验器 # gen-error-codes / verify-cli-references / verify-help @@ -164,7 +165,7 @@ packages/templates/ # 模板源 + registry.json(被 go:embed) apps/docs/ # 文档站 Next.js + Fumadocs apps/dashboard/ # `one serve` 用的 React + Vite UI(被 go:embed) .github/workflows/ # ci / cli / docs -Taskfile.yml / .goreleaser.yaml # 顶层编排(路径都按上面这套) +mise.toml / .goreleaser.yaml # 顶层编排(路径都按上面这套) pnpm-workspace.yaml # apps/* + packages/* DESIGN.md / apps/docs/design/ # 设计源 ``` @@ -173,7 +174,7 @@ DESIGN.md / apps/docs/design/ # 设计源 - 找 issues / discussions on GitHub - 看 [CLAUDE.md](./CLAUDE.md) 的"Don't"列表(避免常踩的坑) -- 查命令:`task --list` / `one --help` +- 查命令:`mise tasks ls` / `one --help` ## License diff --git a/README.md b/README.md index aa055e6..c0cf191 100644 --- a/README.md +++ b/README.md @@ -94,21 +94,14 @@ one add nestjs-api --name api | `one env` | Review and manage environment variables | | `one login` | Sign in to Infisical with your browser | | `one serve` | Inspect workspaces, manage the current account and shared credentials | -| `one ci [enable\|sync\|disable]` | Optionally manage generated GitHub Actions workflows | +| `one run [task]` | Discover and execute workspace tasks through mise | +| `one exec -- ` | Execute a command with the selected project environment | Full command docs live at [1cli.dev](https://1cli.dev). ## Work With AI Assistants -Install the bundled `one-cli` skill for your coding agent: - -```bash -one skills install -``` - -Use `--agent ` to choose an agent directly, or `--yes` to install into every detected agent. Installation works offline and only writes the selected agents' user skills directories. - -New workspaces include an `AGENTS.md` asking agents to use this skill and run `one skills install` if it is missing. The [skill](./skills/one-cli/SKILL.md) stays small: follow [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention), then consult `one --help` and the relevant command help. Ordinary CLI upgrades do not require reinstalling it. +New workspaces include bilingual `AGENTS.md` guidance. Agents can inspect `one run --list -o json`, preview tasks with `one run build --dry-run`, and consult command-specific help. You can ask an assistant for project-level changes in natural language, for example: @@ -126,7 +119,7 @@ One CLI manages local dotenv and Infisical variables. Run `one login` to sign in Run `one serve` for account settings, workspaces, and shared credentials. Workspace and project configuration changes share one reviewed, revision-checked Manifest draft. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. -Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one run --global --env dev --path /folder --keys KEY -- command`. Explicit scope and best-effort masking reduce accidental exposure; they do not isolate arbitrary programs running as the same OS user. Use least-privilege remote permissions. +Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one exec --global --env dev --path /folder --keys KEY -- command`. Explicit scope and best-effort masking reduce accidental exposure; they do not isolate arbitrary programs running as the same OS user. Use least-privilege remote permissions. ## Project Map @@ -142,7 +135,7 @@ If you want to work on One CLI itself, the repository is organized like this: |---|---| | `packages/cli` | The One CLI app | | `packages/templates` | Starters used by `one add` | -| `skills/one-cli` | Minimal workspace guidance installed by `one skills install` | +| `mise.toml` | Workspace scheduling, tools, and artifact cache declarations | | `apps/docs` | Documentation website | | `apps/dashboard` | Local workspace, account, and global-variable Dashboard opened by `one serve` | | `assets` | Brand assets, including the logo | @@ -151,10 +144,10 @@ Common contributor commands: ```bash pnpm install -task check -task build -task test -task verify-docs +mise run check +mise run build +mise run test +mise run verify-docs ``` Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. @@ -182,24 +175,10 @@ one dev web api --ui=stream # Use continuous prefixed logs one build web api --concurrency=4 # Build ready tasks concurrently, respecting local dependencies ``` -`--ui=auto` uses a native terminal for one task and a TUI for multiple tasks. -Override it with `raw`, `tui`, or `stream`. TUI and raw require an interactive -terminal with text output; CI, pipes, and JSON/YAML output use streaming logs. -Structured results remain on stdout and task logs go to stderr. `--dry-run` -only prints the selected execution plan. - -In the TUI, use ↑/↓ to select a project, Enter to send it keyboard input, and -Ctrl+] to return to navigation. PgUp/PgDn scroll history, f resumes following, -/ searches projects, and h hides the project list. In dev, r restarts the selected -project and s stops it. Ctrl+C in navigation stops the session and its process -trees. Ctrl+C in input mode is sent to the selected application. By default any -dev process exiting stops the group; `--keep-going` keeps the other projects alive. - -Build concurrency defaults to 1. Selected local Node dependencies run first; -project selection does not implicitly add unselected dependencies. Failed builds -stop new scheduling, finish already running independent builds, and block tasks -that depend on the failure. Build sessions return to the shell automatically. - -Interactive task terminals currently support Unix (including Linux and macOS). -Windows supports native single-task output and streaming multiple tasks; auto -falls back to streaming until a ConPTY adapter is available. +Development uses native output for one project and the supervisor TUI for multiple projects. Finite tasks use mise streaming output; `one build web --ui raw` preserves interactive input and disables artifact caching. Structured results stay on stdout and child logs go to stderr. + +In the development TUI, use ↑/↓ to select a project, Enter to send input, and Ctrl+] to return to navigation. PgUp/PgDn scroll history, f resumes following, / searches, and h hides the project list. Use r to restart and s to stop the selected project. Ctrl+C stops the session and its process trees. + +Build concurrency defaults to 1. Local Node dependencies are included automatically and build before their consumers. `one run build --cache off --force` always executes the build. See the [task guide](apps/docs/content/docs/en/run.md) for cache declarations and Actions examples. + +The development TUI supports Linux and macOS. Windows uses native single-project output and streaming multiple projects. diff --git a/Taskfile.yml b/Taskfile.yml deleted file mode 100644 index f823e95..0000000 --- a/Taskfile.yml +++ /dev/null @@ -1,407 +0,0 @@ -version: '3' - -# Taskfile for one-cli. After the monorepo restructure the Go module lives at -# packages/cli/, the docs site at apps/docs/, and the `one serve` UI at -# apps/dashboard/. Run `task` (no args) for the menu of available targets. - -vars: - PKG: github.com/torchstellar-team/one-cli/packages/cli - CLI_DIR: packages/cli - KERNEL_DIR: packages/kernel - BIN_DIR: packages/cli/bin - GOEXE: - sh: go env GOEXE - BIN: 'packages/cli/bin/one{{.GOEXE}}' - GOPROXY: '{{.GOPROXY | default "https://goproxy.cn,direct"}}' - # Release builds pass RELEASE_VERSION explicitly. Ordinary local builds use - # a clearly non-release value so no tracked file can drift from a Git tag. - BUILD_VERSION: '{{.RELEASE_VERSION | default "0.0.0-dev"}}' - LDFLAGS: '-s -w -X main.version={{.BUILD_VERSION}}' - LOCAL_VERSION: - sh: | - base="${RELEASE_VERSION:-0.0.0-dev}" - commit=$(git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) - dirty="" - if [ -n "$(git status --porcelain 2>/dev/null)" ]; then dirty=".dirty"; fi - echo "${base}-local.${commit}${dirty}" - LOCAL_LDFLAGS: '-s -w -X main.version={{.LOCAL_VERSION}}' - LOCAL_GO_PROCS: '{{.LOCAL_GO_PROCS | default "2"}}' - -env: - GOPROXY: '{{.GOPROXY}}' - -tasks: - # Keep cached tasks at the repository root so sources/generates resolve - # against the same paths. Use go -C when a command needs the CLI directory. - default: - desc: List available tasks - cmds: - - task --list - - build: - desc: Build the local binary into packages/cli/bin/one - deps: [sync-bundled, sync-web] - status: - - test -f '{{.BIN}}' - - test "$({{.BIN}} --version)" = "{{.BUILD_VERSION}}" - cmds: - - mkdir -p '{{.BIN_DIR}}' - - go -C '{{.CLI_DIR}}' build -trimpath -ldflags '{{.LDFLAGS}}' -o 'bin/one{{.GOEXE}}' ./cmd/one - sources: - - 'Taskfile.yml' - - 'go.work' - - 'go.work.sum' - - 'packages/kernel/go.mod' - - 'packages/kernel/**/*.go' - - 'packages/cli/cmd/**/*.go' - - 'packages/cli/internal/**/*.go' - - 'packages/cli/internal/platform/i18n/locales/*.json' - - 'packages/cli/internal/resources/bundled/**/*' - - 'packages/cli/pkg/**/*.go' - - 'packages/cli/go.mod' - - 'packages/cli/go.sum' - generates: - - '{{.BIN}}' - - gen-error-codes: - desc: Render apps/docs/content/docs/zh/error-codes.md from internal/platform/errors/codes.go - cmds: - - go -C '{{.CLI_DIR}}' run ./tools/gen-error-codes - sources: - - 'packages/cli/internal/platform/errors/codes.go' - - 'packages/cli/tools/gen-error-codes/**/*.go' - generates: - - 'apps/docs/content/docs/zh/error-codes.md' - - verify-cli-references: - desc: Refuse `one ` references in docs that name unregistered commands - # The tool imports internal/bootstrap/cli → internal/resources/bundled, so it can't compile - # without the //go:embed sources being present. sync-bundled covers - # registry/_templates; sync-web covers _web. Both run before - # `go run` here so a fresh checkout works without prior task invocations. - deps: [sync-bundled, sync-web] - cmds: - - go -C '{{.CLI_DIR}}' run ./tools/verify-cli-references - sources: - - 'packages/cli/internal/**/*.go' - - 'packages/cli/tools/verify-cli-references/**/*.go' - - 'README.md' - - 'skills/**/*.md' - - 'CONTRIBUTING.md' - - 'apps/docs/content/docs/**/*.md' - - 'apps/docs/content/docs/**/*.mdx' - - 'packages/templates/*/README.md' - - 'packages/templates/*/README.md.hbs' - - verify-preset-codes: - desc: Lock the v1 preset code table (template + env) — codes are append-only and never re-used - # The test package imports internal/core/template → internal/resources/bundled, so it - # needs the embed sources present. Mirrors verify-cli-references. - deps: [sync-bundled, sync-web] - cmds: - - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestTemplateCodesMatchGoldenAndRegistry|TestEnvCodesMatchGolden|TestGoldenSortedByCode' ./internal/modules/preset/... - sources: - - 'packages/cli/internal/modules/preset/**/*.go' - - 'packages/cli/testdata/preset/v1_codes.json' - - 'packages/templates/registry.json' - - verify-preset-vectors: - desc: Lock the v1 preset id encoding — Encode(spec) and Parse(id) roundtrip every frozen vector - deps: [sync-bundled, sync-web] - cmds: - - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestEncodeVectors|TestParseEncodeRoundTrip|TestEncodeOrderIndependent' ./internal/modules/preset/... - sources: - - 'packages/cli/internal/modules/preset/**/*.go' - - 'packages/cli/testdata/preset/v1_vectors.json' - - verify-help: - desc: Refuse drift in cobra help text (rootHelp completeness + Example flag existence) - # The tool imports internal/bootstrap/cli → internal/resources/bundled, so it can't compile - # without the //go:embed sources being present. sync-bundled covers - # registry/_templates; sync-web covers _web. Mirrors the deps - # on verify-cli-references and verify-preset-codes. - deps: [sync-bundled, sync-web] - cmds: - - go -C '{{.CLI_DIR}}' run ./tools/verify-help - sources: - - 'packages/cli/internal/**/*.go' - - 'packages/cli/internal/platform/i18n/locales/*.json' - - 'packages/cli/tools/verify-help/**/*.go' - - verify-docs: - desc: All documentation drift checks (CLI references + help text + frontmatter presence + preset codes / vectors) - cmds: - - task: verify-cli-references - - task: verify-help - - task: verify-preset-codes - - task: verify-preset-vectors - - go run ./packages/cli/tools/repo-checks frontmatter - - verify-node-workspace: - desc: Keep application dependency resolution owned by the root pnpm workspace - cmds: - - go run ./packages/cli/tools/repo-checks node-workspace - - hooks:install: - desc: Enable the repository-managed Git hooks for this checkout - summary: | - Configures this checkout to load hooks from .githooks/. The pre-commit - hook runs `task check`, which is the same PR gate used by GitHub Actions. - cmds: - - git config --local core.hooksPath .githooks - - echo "Git pre-commit hook enabled (task check)" - - install: - aliases: [install-local] - desc: Build the CLI, install its launcher, and enable repository Git hooks - summary: | - One-shot developer install: builds the Dashboard, refreshes all bundled - assets, compiles the CLI with local version metadata, and creates a launcher - under ~/.local/bin pointing at the absolute binary in this checkout. - macOS/Linux use a one symlink. Windows prefers a one.exe symlink and - falls back to a one.cmd forwarding shim when symlinks are unavailable. - Both keep working when later builds replace the binary in place. - - `task install-local` remains a compatibility alias. - - Idempotent — safe to re-run after switching branches. - - Requires ~/.local/bin to be on PATH. Add to your shell rc if not: - export PATH="$HOME/.local/bin:$PATH" - deps: [build-local] - cmds: - - task: hooks:install - - go run ./packages/cli/tools/install-local "{{.BIN}}" - - build-local: - internal: true - deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' - cmds: - - mkdir -p bin - - cmd: go build -p {{.LOCAL_GO_PROCS}} -trimpath -ldflags '{{.LOCAL_LDFLAGS}}' -o 'bin/one{{.GOEXE}}' ./cmd/one - env: - GOMAXPROCS: '{{.LOCAL_GO_PROCS}}' - - build-all: - desc: Cross-compile every supported platform under go-dist/ - deps: [sync-bundled, sync-web] - dir: '{{.CLI_DIR}}' - cmds: - - rm -rf ../../go-dist - - mkdir -p ../../go-dist - - for: ['darwin/amd64', 'darwin/arm64', 'linux/amd64', 'linux/arm64', 'windows/amd64'] - cmd: | - plat={{.ITEM}} - os=${plat%/*} - arch=${plat#*/} - out=../../go-dist/one-${os}-${arch} - [ "$os" = "windows" ] && out="${out}.exe" - echo "→ $out" - GOOS=$os GOARCH=$arch go build -trimpath -ldflags '{{.LDFLAGS}}' -o "$out" ./cmd/one - - check: - desc: Run the complete monorepo PR gate - summary: | - Single local and CI entrypoint for One CLI's Go workspace and embedded - Dashboard. Static checks and tests remain separate subtasks so CI can run - them in parallel while `task check` preserves the same contract locally. - cmds: - - task: check:static - - task: check:test - - check:static: - desc: Run documentation, Go, and Dashboard static checks - cmds: - - task: verify-node-workspace - - task: verify-docs - - task: vet - - task: fmt:check - - task: check:dashboard - - check:test: - desc: Build the binary and run Go plus Dashboard tests without race detection - cmds: - - task: test:go:plain - - task: test:dashboard - - check:dashboard: - desc: Run Dashboard lint and formatting checks - cmds: - - pnpm --dir apps/dashboard run check - - fmt:check: - desc: Check Go formatting without rewriting files - cmds: - - go run ./packages/cli/tools/repo-checks gofmt - - test: - desc: Run Go tests with race detector and Dashboard interaction tests - cmds: - - task: test:go - - task: test:dashboard - - test:go: - desc: Run kernel and CLI tests with race detector - deps: [build] - cmds: - - go test -race ./{{.KERNEL_DIR}}/... - - go test -race ./{{.CLI_DIR}}/... - - test:go:plain: - desc: Run kernel and CLI tests without race detector - deps: [build] - cmds: - - go test ./{{.KERNEL_DIR}}/... - - go test ./{{.CLI_DIR}}/... - - test:dashboard: - desc: Run Dashboard interaction tests - deps: [build-web] - cmds: - - pnpm --dir apps/dashboard run test - - test:snapshot: - desc: Run only the JSON-snapshot tests against testdata/reference - deps: [build] - dir: '{{.CLI_DIR}}' - cmds: - - go test -run Snapshot ./... - - pre-push: - desc: Run the PR gate plus Go race tests before pushing - summary: | - Runs `task check`, the exact PR CI contract, then repeats the Go suites - under the race detector. Dashboard architecture, lint, format, build, and - interaction tests are already covered by `task check`. - - Run before every push. Cheap enough (~10–30s on a warm cache) that you - shouldn't have to skip it. The first run on a fresh clone takes longer - because sync-web bootstraps the dashboard via pnpm install + vite build. - cmds: - - task: check - - task: test:go - - fmt: - desc: gofmt the codebase - cmds: - - go fmt ./{{.KERNEL_DIR}}/... - - go fmt ./{{.CLI_DIR}}/... - - vet: - desc: go vet kernel and CLI modules - deps: [sync-bundled, sync-web] - cmds: - - go vet ./{{.KERNEL_DIR}}/... - - go vet ./{{.CLI_DIR}}/... - - tidy: - desc: Tidy every Go workspace module - cmds: - - go -C {{.KERNEL_DIR}} mod tidy - - go -C {{.CLI_DIR}} mod tidy - - go work sync - - clean: - desc: Remove packages/cli/bin/ and go-dist/ - cmds: - - rm -rf {{.BIN_DIR}} go-dist - - dev: - desc: Run the Go Dashboard API and Vite UI together - summary: | - Starts the current Go source on http://127.0.0.1:5174 and the Vite - Dashboard on http://localhost:5173. Open that Vite URL directly. - - Workspace and Project data comes from the checked-in Dashboard fixture. - Login and global-variable operations use the real local Infisical session. - Workspace configuration is scoped to the fixture Workspace root. - - Both processes stop together when you press Ctrl-C. - deps: [sync-bundled, sync-web] - cmds: - - task --parallel --failfast --output prefixed dev:serve dev:dashboard - - dev:serve: - dir: '{{.CLI_DIR}}/testdata/dashboard-dev-workspace' - cmds: - - go run ../../cmd/one serve --port 5174 --open=false -o text - - dev:dashboard: - env: - VITE_DEV_API_TARGET: http://127.0.0.1:5174 - VITE_DEV_DATA_MODE: fixture-live-session - cmds: - - pnpm --filter one-serve-web dev - - build-web: - desc: Build the embedded `one serve` web UI (apps/dashboard/ → apps/dashboard/dist/) - summary: | - Builds the React UI shipped with `one serve` (sources at apps/dashboard/). - Output lands at apps/dashboard/dist/. The bundled mirror at - packages/cli/internal/resources/bundled/_web/ is refreshed by `task sync-web`, - which depends on this task. - - Idempotent: reruns cheaply when sources are unchanged (task hashes - sources/generates fingerprints). - - Requires Node + pnpm locally and in CI. Dependencies are resolved from - the repository root pnpm-lock.yaml; app-level lockfiles are forbidden. - Edit the UI → run `task sync-web` to rebuild the ignored embedded mirror. - cmds: - - pnpm --filter one-serve-web install --frozen-lockfile - - pnpm --dir apps/dashboard build - sources: - - 'apps/dashboard/src/**/*' - - 'apps/dashboard/index.html' - - 'apps/dashboard/package.json' - - 'apps/dashboard/vite.config.ts' - - 'apps/dashboard/tsconfig*.json' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - generates: - - 'apps/dashboard/dist/index.html' - - sync-bundled: - desc: Regenerate registry/_templates/_skills under packages/cli/internal/resources/bundled (go:embed sources) - run: once - summary: | - Go's embed cannot traverse `..` or follow symlinks, so we keep physical - copies of canonical assets under packages/cli/internal/resources/bundled. The - whole bundled tree is gitignored and regenerated on demand. - - This task handles the cheap copies: registry.json, _templates/, _skills/. - It runs implicitly as a dep of `vet` / `test` / `build`. The dashboard - build (sync-web) is separate but identically wired — both have to run - before any Go command will compile the bundled package. - - Direct `go build` / IDE-driven `gopls` won't run any task, so a fresh - clone needs `task sync-bundled && task sync-web` once before the Go - toolchain stops complaining about missing embed paths. - - Agent instruction files and .one/.agents directories are excluded from - the bundled templates. Dev-only go.mod files inside packages/templates// are stripped - from the bundled copy — their presence isolates each Go-based template - from the parent module during repository development, but go:embed - treats any directory containing go.mod as a separate module and - excludes its subtree, which would silently drop the go-api template - from the binary. - cmds: - - go run ./packages/cli/tools/sync-resources bundled - - sync-web: - desc: Rebuild the embedded `one serve` UI from apps/dashboard/ and refresh packages/cli/internal/resources/bundled/_web/ - run: once - summary: | - Requires Node + pnpm — both CI and local dev need them. _web/ is - gitignored; this task is the only path that creates it. Runs as a - dep of `vet` / `test` / `build`, so the normal Taskfile flow always - keeps it fresh. - - First run on a clean checkout takes 30–90s (pnpm install + vite build). - Subsequent runs hit the task-level sources/generates fingerprint and - are near-free unless you've edited apps/dashboard/. - deps: [build-web] - cmds: - - go run ./packages/cli/tools/sync-resources web diff --git a/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx b/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx index b4a1c7b..986d14b 100644 --- a/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx +++ b/apps/dashboard/src/features/project-settings/forms/GeneralForm.tsx @@ -128,6 +128,54 @@ export const GeneralForm: React.FC = ({
+ {project.tasks && ( +
+ + {project.tasks.status === "unavailable" ? ( +

+ {t("projectInspector.general.tasksUnavailable")} +

+ ) : project.tasks.entries.length === 0 ? ( +

+ {t("projectInspector.general.tasksEmpty")} +

+ ) : ( + project.tasks.entries.map((task) => ( +
+

{task.name}

+

+ {t("projectInspector.general.taskSource", { source: task.source })} +

+

+ {t( + task.cacheEnabled + ? "projectInspector.general.taskCacheOn" + : "projectInspector.general.taskCacheOff", + )} +

+ {!!task.depends?.length && ( +

+ {t("projectInspector.general.taskDepends", { tasks: task.depends.join(", ") })} +

+ )} + {!!task.outputs?.length && ( +

+ {t("projectInspector.general.taskOutputs", { + outputs: task.outputs.join(", "), + })} +

+ )} +
+ )) + )} +
+ )} ); }; diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index 483580b..d9fed7f 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -352,12 +352,20 @@ "packageManagerAutomatic": "Detect automatically", "devCommand": "Development command", "buildCommand": "Build command", - "buildSource": "one build reads {{source}}. Edit that file to change the build task.", + "buildSource": "one build reads {{source}}. Edit that file to change the build task. Workspace dependencies and caching are managed by mise; one run build uses the same entry point.", "buildMissing": "No build task configured", "buildUnavailable": "Unable to read build configuration", "buildUnsupported": "No build task is available for this project.", "metadata": "Project information", - "runtime": "Development and build" + "runtime": "Development and build", + "tasks": "Configured tasks", + "tasksUnavailable": "Task configuration could not be read. Check it with one run.", + "tasksEmpty": "No project tasks are configured.", + "taskSource": "Source: {{source}}", + "taskCacheOn": "Artifact caching is configured.", + "taskCacheOff": "Artifact caching is not configured.", + "taskDepends": "Dependencies: {{tasks}}", + "taskOutputs": "Outputs: {{outputs}}" }, "environment": { "title": "Environment configuration", diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 13b7fc5..b8c6b24 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -352,12 +352,20 @@ "packageManagerAutomatic": "自动检测", "devCommand": "开发命令", "buildCommand": "构建命令", - "buildSource": "one build 自动读取 {{source}},请在该文件中修改构建任务。", + "buildSource": "one build 自动读取 {{source}},请在该文件中修改构建任务。 工作区依赖与缓存由 mise 管理,one run build 使用相同入口。", "buildMissing": "未配置构建任务", "buildUnavailable": "无法读取构建配置", "buildUnsupported": "此项目没有可用的构建任务。", "metadata": "项目信息", - "runtime": "开发与构建" + "runtime": "开发与构建", + "tasks": "已配置任务", + "tasksUnavailable": "无法读取任务配置,请使用 one run 检查。", + "tasksEmpty": "此项目没有配置任务。", + "taskSource": "来源:{{source}}", + "taskCacheOn": "已配置产物缓存。", + "taskCacheOff": "未配置产物缓存。", + "taskDepends": "依赖:{{tasks}}", + "taskOutputs": "产物:{{outputs}}" }, "environment": { "title": "环境配置", diff --git a/apps/dashboard/src/pages/Overview.test.tsx b/apps/dashboard/src/pages/Overview.test.tsx index ce75b91..eb9e3bf 100644 --- a/apps/dashboard/src/pages/Overview.test.tsx +++ b/apps/dashboard/src/pages/Overview.test.tsx @@ -508,6 +508,38 @@ describe("workspace overview Profile-only configuration", () => { }, ); + it("shows configured task dependencies and cache settings without claiming cache hits", async () => { + server.use( + http.get("http://localhost/api/workspace/projects/web", () => + HttpResponse.json({ + ...webSettings, + project: { + ...webSettings.project, + tasks: { + status: "ready", + entries: [ + { + name: "//apps/web:build", + source: "apps/web/mise.toml", + depends: ["//packages/lib:build"], + outputs: ["dist"], + cacheEnabled: true, + }, + ], + }, + }, + }), + ), + ); + renderOverview(); + const inspector = await openProjectSettings(); + const tasks = await within(inspector).findByRole("region", { name: "Configured tasks" }); + expect(within(tasks).getByText("//apps/web:build")).toBeDefined(); + expect(within(tasks).getByText("Dependencies: //packages/lib:build")).toBeDefined(); + expect(within(tasks).getByText("Artifact caching is configured.")).toBeDefined(); + expect(within(tasks).queryByRole("button")).toBeNull(); + }); + it("keeps project environment configuration separate from remote secret operations", async () => { const user = userEvent.setup(); renderOverview(); diff --git a/apps/dashboard/src/types/api.ts b/apps/dashboard/src/types/api.ts index 197344a..b8f4e07 100644 --- a/apps/dashboard/src/types/api.ts +++ b/apps/dashboard/src/types/api.ts @@ -177,6 +177,16 @@ export interface ProjectSettings { defaultEnvironment?: string; availableEnvironments?: string[]; environment: ProjectEnvironmentSettings; + tasks?: { + status: "ready" | "unavailable"; + entries: Array<{ + name: string; + source: string; + depends: string[] | null; + outputs: string[] | null; + cacheEnabled: boolean; + }>; + }; } export interface ProjectSettingsResponse { diff --git a/apps/docs/content/blog/en/agent-skill-context.md b/apps/docs/content/blog/en/agent-skill-context.md index c46f27a..d462450 100644 --- a/apps/docs/content/blog/en/agent-skill-context.md +++ b/apps/docs/content/blog/en/agent-skill-context.md @@ -6,7 +6,7 @@ author: "One CLI Team" tags: ["skill", "codex", "dependencies"] --- -> Historical design note: the detailed playbooks described below have been replaced by a minimal `one-cli` skill. See [Skills](/en/docs/skills/) for the current `one skills install` workflow. +> Historical design note: the bundled skill feature has been removed. Current task usage is documented in [one run](/en/docs/run/). ## A skill is not marketing copy diff --git a/apps/docs/content/blog/zh/agent-skill-context.md b/apps/docs/content/blog/zh/agent-skill-context.md index 610a3af..8d8cd28 100644 --- a/apps/docs/content/blog/zh/agent-skill-context.md +++ b/apps/docs/content/blog/zh/agent-skill-context.md @@ -6,7 +6,7 @@ author: "One CLI Team" tags: ["skill", "codex", "dependencies"] --- -> 历史设计记录:下文的详细 playbook 已由精简的 `one-cli` skill 替代。当前 `one skills install` 用法见 [Skills](/zh/docs/skills/)。 +> 历史设计记录:内置 skill 功能已移除。当前任务用法见 [one run](/zh/docs/run/)。 ## skill 不是营销文档 diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md index 1cced8e..6908d4d 100644 --- a/apps/docs/content/docs/en/add.md +++ b/apps/docs/content/docs/en/add.md @@ -129,5 +129,4 @@ Not sure which one to use? Read the [template decision tree](/en/docs/templates/ - Check `one.manifest.json#projects[]` to confirm registration - Agent docs and local-development configuration are synced by `one add` - Run `one dev ` for development and `one build ` to build -- Optionally run `one ci enable ` to generate its GitHub Actions workflow - `one add` does not install dependencies: JS / TS workspaces install from the root with the package manager; Go projects run `go mod download` in the project directory, then `go mod tidy` only after changing imports or when module metadata needs repair diff --git a/apps/docs/content/docs/en/ai-native.md b/apps/docs/content/docs/en/ai-native.md index 29e04ca..4defc1a 100644 --- a/apps/docs/content/docs/en/ai-native.md +++ b/apps/docs/content/docs/en/ai-native.md @@ -97,9 +97,7 @@ The full catalogue is in [Error codes](/en/docs/error-codes/). ## Rule 3: Project Instructions Belong To The Team -New workspaces include a short root `AGENTS.md` requiring the `one-cli` skill. If it is missing, agents are directed to run `one skills install`. Ordinary and preset creation use the same instructions; subsequent `one add` calls preserve team edits. -The skill follows [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention) and directs agents to the installed CLI help. Agents can also read `one.manifest.json` and project README files. Skill installation is explicit and independent of workspace creation; see [Skills](./skills). ## Rule 4: Configuration And Credentials Have Boundaries diff --git a/apps/docs/content/docs/en/build.md b/apps/docs/content/docs/en/build.md index d56bd18..9f399c9 100644 --- a/apps/docs/content/docs/en/build.md +++ b/apps/docs/content/docs/en/build.md @@ -1,43 +1,19 @@ --- title: one build -description: Build all projects or one selected project. +description: Build selected projects and their local dependencies through mise. --- -`one build` prepares tools and application dependencies, then runs project build tasks to completion. +`one build` is the convenience entry point for `one run build`. Both use the same task planner, environment snapshot, mise invocation, cache, and output schema. -```bash +```sh one build -one build web -one build apps/web +one build web api one build -p web --env prod -one build --dry-run -o json +one build web --dry-run -o json +one build --cache off --force +one build web --ui raw ``` -Without a selector, builds all buildable manifest projects, even when invoked from a project directory. A project name or workspace-relative path selects only that project; its local dependencies are not automatically built. +Node commands come from `package.json` and use pnpm. Go commands come from Taskfile. Full builds include available build tasks; explicitly selecting a project without one fails. Local Node dependencies build before their consumers. Default concurrency is one; `--concurrency 4` allows independent tasks to run in parallel. -## Build commands - -The Dashboard project overview shows the resolved build command and its source. This field is read-only; edit the project's `package.json` or `Taskfile.yml`, then refresh the page to see the update. - -- Node: runs `build` from the current `package.json` using the workspace package manager (`pnpm`, `npm`, `yarn`, or `bun`). -- Go: runs `task build` from the project's `Taskfile.yml`. The Go API template writes `bin/server`; the Go library template compiles packages with `go build ./...`. Older libraries can add that task to their Taskfile. - -Go projects require a Taskfile. Full workspace builds skip projects without a build task and report `no-build-task`; explicitly selecting one fails with `RUNTIME_TASK_NOT_FOUND`. Invalid configuration and missing required files fail before preparation. A workspace with no build tasks also fails. Build scripts control artifact locations. - -## Ordering and execution - -Full builds run sequentially, with local Node dependencies before their consumers. Dependencies, devDependencies, and optionalDependencies are matched by package name, or by directory for `file:` / `link:` dependencies. Independent projects retain manifest traversal order. Duplicate package names and dependency cycles are reported before execution. Go resolves its package dependencies through the Go toolchain. - -The first failure stops the build. Remaining tasks are reported as `not_run`, and the failing child's exit code is preserved. Ctrl+C stops the active process tree. Each project's logs carry its name. - -## Tools, dependencies, and environments - -Uses the same automatic mise/builtin selection and dependency preparation as `one dev`, including libraries without dev commands. Node dependencies are installed once at the workspace root. Each build runs through the `one run` environment-loading and PATH rules, in its project directory. `--env` selects an environment; otherwise the manifest default applies. - -`--dry-run` reads configuration and reports ordered commands, directories, and skipped projects. It does not install tools or dependencies, load secrets, access the network, or write files. - -## Output - -`-o json` and `-o yaml` return `one-cli/build-plan/v1` for previews and `one-cli/build-result/v1` for execution results. Process logs go to stderr, leaving stdout parseable. Results include per-project status, command, duration, and exit code. Preparation failures include an error and leave build tasks `not_run`. - -Use [`one run`](/docs/run/) for custom commands. +The Dashboard displays the project build command and its source. Edit the source file to change it. Workspace dependencies and cache settings live in mise configuration. See [one run](/en/docs/run/) for task management, custom caching, terminal modes, and Actions examples. diff --git a/apps/docs/content/docs/en/ci.md b/apps/docs/content/docs/en/ci.md deleted file mode 100644 index aafa2c0..0000000 --- a/apps/docs/content/docs/en/ci.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Continuous integration -description: Inspect, enable, refresh, or remove optional CI workflows. ---- - -Continuous integration is opt-in. `one create` and `one add` do not generate -workflow files. The current build supports GitHub Actions. - -## Usage - -```bash -one ci -one ci enable [project] -one ci sync [project] -one ci disable [project] -``` - -`one ci` is read-only and shows each project's current state and the most useful -next command. - -## Commands - -| Command | Behavior | -|---|---| -| `one ci` | Show CI status for all projects | -| `one ci enable web` | Generate the canonical workflow for `web` | -| `one ci enable` | Enable CI for every project | -| `one ci sync web` | Regenerate `web` when CI is already enabled | -| `one ci sync` | Regenerate every workflow that already exists | -| `one ci disable web` | Remove `web`'s generated workflow after confirmation | -| `one ci disable --yes` | Remove all generated workflows without prompting | - -Refusing the disable confirmation or pressing Ctrl-C is a normal cancellation: -the command exits successfully and does not modify files. - -Non-interactive calls never treat the absence of a terminal as consent. Pass -`--yes` explicitly or the command returns `CI_DISABLE_CONFIRMATION_REQUIRED` -without removing any workflow. - -## Files and workspace state - -For GitHub Actions, One CLI writes one canonical file per project under -`.github/workflows/`. CI selection is not written to `one.manifest.json`; the -generated file is the state. `enable`, `sync`, and `disable` therefore leave the -manifest unchanged. - -## Automation - -```bash -one ci -o json -one ci enable web --provider ci/github-actions -o json -one ci sync --project web -o json -one ci disable web --yes -o json -``` - -Stable schemas are `one-cli/ci-status/v1`, `one-cli/ci-enable/v1`, -`one-cli/ci-sync/v1`, and `one-cli/ci-disable/v1`. The provider ID and JSON field -names remain stable and are not translated. `--project` is retained for older -scripts; everyday help prefers the positional project. - -## Errors - -| Code | Recovery | -|---|---| -| `CI_DISABLE_CONFIRMATION_REQUIRED` | Review the target projects, then rerun with `--yes` | -| `CI_NOT_ENABLED` | Run `one ci enable `, then retry sync | -| `CI_PROVIDER_UNKNOWN` | Use an ID from `error.context.available_providers` | -| `CI_RENDER_FAILED` | Inspect the project and workflow path in the error context | - -See [Error codes](/en/docs/error-codes/) for the stable error envelope. diff --git a/apps/docs/content/docs/en/cli-overview.md b/apps/docs/content/docs/en/cli-overview.md index d9706d1..daab99e 100644 --- a/apps/docs/content/docs/en/cli-overview.md +++ b/apps/docs/content/docs/en/cli-overview.md @@ -12,10 +12,12 @@ description: Daily commands and advanced entry points. | `one env` | Manage project variables | | `one login` / `one whoami` / `one logout` | Single browser session | | `one env --global` | Discover global variable locations and environments | -| `one run` | Run a command with injected variables | +| `one exec` | Run a command with injected variables | | `one serve` | Open the Dashboard | | `one locale` | Local language preference | | `one init mise` / `one init hooks` | Workspace tool configuration | -| `one ci` / `one templates` / `one skills` | Automation and resources | +| `one run` | List and execute workspace tasks | +| `one templates` | List project templates | +| `one hk` | Run Git hook checks | Discover the full catalogue with `one help --all`, then read command-specific `--help`. Agents discover metadata and execution options through the CLI, without copying commands from the Dashboard. See [login and shared credentials](/en/docs/login/). diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md index 57412ab..4551656 100644 --- a/apps/docs/content/docs/en/create.md +++ b/apps/docs/content/docs/en/create.md @@ -50,7 +50,7 @@ one create my-app --yes --env-provider infisical Continuous integration is not configured automatically. Creating a workspace does not write files under `.github/workflows/`. After adding a project, enable -it explicitly with `one ci enable ` if needed. +a workflow that calls `one run ci` when needed. ## `--env-provider` Semantics @@ -139,4 +139,5 @@ Full table: [Error codes](/en/docs/error-codes/). ## Agent instructions -Ordinary and preset creation write a short root `AGENTS.md` requiring the `one-cli` skill and directing agents to `one skills install` when it is missing. Skill installation is a separate user-level operation. Later `one add` calls preserve this file, including team edits. + +New workspaces include bilingual `AGENTS.md` guidance for `one run`, `one exec`, pnpm, and Task. Later project additions preserve team edits. diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index ed962f1..a030b0c 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -27,7 +27,7 @@ one dev [project] [--dry-run] - Node dependencies are prepared at the workspace root. With pnpm 10.14 or later, One asks pnpm to verify the installed workspace state and reuses a matching installation, including one created by a manual `pnpm install`. When installation is needed, it runs `pnpm install --no-frozen-lockfile` so new projects and dependency changes can update the lockfile. Older pnpm versions use One's installation cache. Other package managers keep their existing lockfile policy. - `one build` keeps the strict installation policy: an existing pnpm dependency lockfile uses `--frozen-lockfile`. If it is stale, install dependencies and review the lockfile changes before building. - Go preparation downloads the fixed module build list or resolves workspace dependencies, maintaining checksums as needed. It does not run `go mod tidy` or `go work sync` automatically. -- A failed preparation stops startup. Package-manager diagnostics are streamed once; canceling stops the preparation process. `one run` does not install dependencies. +- A failed preparation stops startup. Package-manager diagnostics are streamed once; canceling stops the preparation process. `one exec` does not install dependencies. ## Runner @@ -41,7 +41,7 @@ one dev apps/web --dry-run ## Scroll and resize the task interface -On Unix, multiple tasks use the TUI in an interactive terminal. Use `one dev web --ui=tui` to open it for a single project. `one build --ui=tui` uses the same log controls. +On Unix, multiple tasks use the TUI in an interactive terminal. Use `one dev web --ui=tui` to open it for a single project. Finite builds use mise streaming output. | Control | Action | |---|---| @@ -70,5 +70,7 @@ History is bounded to 3,000 screen rows, with an 1 MiB replay buffer per project ## Next - [Local dev orchestration](/en/tutorials/dev-local/) -- [one run](/en/docs/run/) +- [one exec](/en/docs/exec/) - [Workspace manifest](/en/docs/manifest/) + +`one run dev` uses this same supervisor. Finite builds of upstream local packages run through mise before development starts. diff --git a/apps/docs/content/docs/en/env-vars.md b/apps/docs/content/docs/en/env-vars.md index 22247a0..3e621ee 100644 --- a/apps/docs/content/docs/en/env-vars.md +++ b/apps/docs/content/docs/en/env-vars.md @@ -249,4 +249,4 @@ Full table: [Error codes](/en/docs/error-codes/). ## Shared credentials -Shared credentials are independent of workspaces. Select storage with `one env bind --global`, browse metadata with `one env list --global --env dev --path /`, and inject an explicit scope with `one run --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/) for commands and security boundaries. +Shared credentials are independent of workspaces. Select storage with `one env bind --global`, browse metadata with `one env list --global --env dev --path /`, and inject an explicit scope with `one exec --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/) for commands and security boundaries. diff --git a/apps/docs/content/docs/en/error-codes.md b/apps/docs/content/docs/en/error-codes.md index 8a35bdc..fdb12bb 100644 --- a/apps/docs/content/docs/en/error-codes.md +++ b/apps/docs/content/docs/en/error-codes.md @@ -37,12 +37,6 @@ Field meanings: - **`error.context`**: structured data from the failure site. It often already contains the data needed for recovery. - **`error.remediation`**: recovery actions. Each item has `action`, `hint`, and sometimes `command`; agents should prefer these before guessing. -## Agent Skill Installation - -### `SKILLS_INSTALL_FAILED` - -The bundled `one-cli` skill could not be installed. Check the agent ID and destination permissions with `one skills install --help`. If some targets were installed before a failure, `context.installed_to` lists those directories. Retrying is safe. - ## Generic / Lifecycle Command-level failures, user cancellation, and internal serialization failures. @@ -160,25 +154,6 @@ A backend sync failed or rolled back after manifest write. Re-run the command af Backend selection and generated workspace configuration. -### `CI_DISABLE_CONFIRMATION_REQUIRED` - -A non-interactive `one ci disable` call did not pass `--yes`. Review the -selected projects and rerun with explicit confirmation; no workflow was removed. - -### `CI_PROVIDER_UNKNOWN` - -The requested CI provider is not implemented by this build. Use the IDs in -`error.context.available_providers`. - -### `CI_NOT_ENABLED` - -The selected project has no generated CI workflow. Run the command in -`error.remediation`, usually `one ci enable `, before syncing it. - -### `CI_RENDER_FAILED` - -CI backend failed while rendering workflow files. - ### `LOCAL_ORCH_PORT_CONFLICT` Two projects requested the same dev port and the runner could not auto-allocate another. diff --git a/apps/docs/content/docs/en/exec.md b/apps/docs/content/docs/en/exec.md new file mode 100644 index 0000000..dc42ac5 --- /dev/null +++ b/apps/docs/content/docs/en/exec.md @@ -0,0 +1,86 @@ +--- +title: one exec +description: Inject project env vars into any command and execute it from the resolved project directory. +--- + +`one exec` behaves like `infisical run` / `dotenv run`: it resolves a project, loads env vars from the workspace env provider, injects them into a child process, and runs the command you pass. + +## Usage + +```bash +one exec [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...] +``` + +You can omit `--`, but scripts should keep it so child flags are not parsed as One CLI flags. + +## Options + +| option | purpose | +|---|---| +| `-p`, `--project ` | select a project; without it, One CLI infers from cwd | +| `--env-provider dotenv|infisical` | force a provider instead of using the workspace manifest | +| `--env ` | use a specific environment | +| `-o`, `--output ` | affects only One CLI output; child stdout/stderr pass through | + +## Interactive Mode + +`one exec` has no wizard. It only resolves the project, environment, and child command from arguments. Keep the `--` separator in scripts because the child command can have its own flags. + +## Examples + +```bash +one exec -- pnpm test +one exec -p web -- pnpm run build +one exec -p apps/web -- pnpm lint +one exec --env-provider dotenv -- pnpm test +one exec --env staging -- pnpm run e2e +``` + +The child process always runs from the resolved project directory, so commands find that project's `package.json`, `Taskfile.yml`, or Go module files. + +## PATH and env + +`one exec` merges loaded variables into the child environment, overriding same-name shell variables. It also prepends: + +```text +/node_modules/.bin +/node_modules/.bin +``` + +This lets pnpm / turbo workspaces invoke `vite`, `next`, `astro`, and similar binaries directly. + +## Env provider + +| provider | behavior | +|---|---| +| `dotenv` | read project `.env` overlays | +| `infisical` | fetch env vars from Infisical | +| empty | use the provider recorded in the workspace manifest | + +`--env-provider infisical` requires browser login with `one login`. Use `--env-provider dotenv` for offline local runs. + +## Common errors + +| code | fix | +|---|---| +| `NOT_ONE_PROJECT` | run inside a workspace or project directory | +| `SUBPROJECT_NOT_FOUND` | pass a manifest `name` or `relativeDir` to `-p` | +| `RUN_COMMAND_NOT_FOUND` | check PATH, project `node_modules/.bin`, and workspace `node_modules/.bin` | +| `ENV_FILE_NOT_FOUND` | create a project `.env` or use `--env-provider infisical` | +| `INFISICAL_AUTH_MISSING` | run `one login` | + +## Next + +- [Run with env vars](/en/tutorials/run-passthrough/) +- [one env](/en/docs/env-vars/) +- [one dev](/en/docs/dev/) + + +## Global credentials + +```bash +one exec --global --env dev --path /oss --keys OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET -- upload-assets +one exec --global --env dev --path /oss --dry-run -- upload-assets +``` + +Environment and folder must be explicit. Only that folder is read; `--keys` fetches only selected variables. Dry-run does not read credentials. Global mode does not load project environments or implicitly resolve repository binaries. It preserves the current directory. Exact-value output masking is best effort, not a sandbox. diff --git a/apps/docs/content/docs/en/installation.md b/apps/docs/content/docs/en/installation.md index 0cc7410..9d392a5 100644 --- a/apps/docs/content/docs/en/installation.md +++ b/apps/docs/content/docs/en/installation.md @@ -107,7 +107,7 @@ Managed mise updates with One; automatic self-updates are disabled for that chil Download, migration, or verification failures return `MISE_INSTALL_FAILED`. Check network/proxy access to GitHub Releases and permissions on One's runtime directory, then retry the same command. Help, dry-run, and static project/configuration generation do not prepare mise. -Use `one mise --version`, `one mise doctor`, or `one mise trust ` to work with the same selected runtime. Review configuration before trusting it; `MISE_PARANOID=1` requires explicit trust. Arguments, IO, and exit codes are forwarded, without One project secrets; use `one run` when those secrets are needed. +Use `one mise --version`, `one mise doctor`, or `one mise trust ` to work with the same selected runtime. Review configuration before trusting it; `MISE_PARANOID=1` requires explicit trust. Arguments, IO, and exit codes are forwarded, without One project secrets; use `one exec` when those secrets are needed. ## Infisical login @@ -157,8 +157,8 @@ If you are changing One CLI itself, read [CONTRIBUTING.md](https://github.com/1c ```bash git clone https://github.com/1cli-team/one-cli cd one-cli -brew install go go-task # macOS; adapt for Linux -task install # package Dashboard + CLI, then create a native launcher +brew install mise # macOS; adapt for Linux +mise run install # package Dashboard + CLI, then create a native launcher hash -r which one one --version @@ -173,7 +173,3 @@ For the full contributor flow, see [CONTRIBUTING.md](https://github.com/1cli-tea ## Installed? Go to [Quick start](/en/docs/quick-start/) and create your first workspace. - -## Agent skill - -Run `one skills install` to install the bundled `one-cli` skill into your coding agent's user skills directory. Use `one skills install --help` for target selection and supported agents. See [Skills](./skills). diff --git a/apps/docs/content/docs/en/login.md b/apps/docs/content/docs/en/login.md index 53dfdc3..f222d92 100644 --- a/apps/docs/content/docs/en/login.md +++ b/apps/docs/content/docs/en/login.md @@ -26,7 +26,7 @@ one env bind --global --project-id PROJECT_ID --env dev one env --global one env list --global --env dev --path / one env list --global --env dev --path /docker -one run --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script +one exec --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script ``` Listings contain immediate folders, names, and descriptions, without values. Execution requires an explicit environment and path. It does not recurse, import other folders, or expand secret references. Use `--keys` to narrow injection further. Global mode works outside a workspace and preserves the current directory. Project commands, `one dev`, and `one build` do not automatically receive shared credentials. diff --git a/apps/docs/content/docs/en/manifest.md b/apps/docs/content/docs/en/manifest.md index 0bc8636..115d7eb 100644 --- a/apps/docs/content/docs/en/manifest.md +++ b/apps/docs/content/docs/en/manifest.md @@ -66,7 +66,7 @@ For Infisical, `domains.env.config` can contain `projectId`, `projectName`, `roo | `one env switch` | Changes the workspace environment source | | `one serve` | Applies explicitly reviewed project or environment-source changes with revision checks | -`one build` selects each project's build command from its toolchain. Node projects use package scripts; Go projects use `Taskfile.yml`. CI is managed separately with `one ci`. +`one build` selects each project's build command from its toolchain. Node projects use package scripts; Go projects use `Taskfile.yml`. Workspace tasks and the ordinary `ci` aggregate run through `one run`. ## Manual edits diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 1fd02be..ed8037c 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -6,7 +6,6 @@ "manifest", "templates", "ai-native", - "skills", "cli-overview", "create", "add", @@ -15,8 +14,8 @@ "templates-cmd", "dev", "build", - "ci", "run", + "exec", "serve", "error-codes" ] diff --git a/apps/docs/content/docs/en/run.md b/apps/docs/content/docs/en/run.md index 4e74e6b..ceee7c8 100644 --- a/apps/docs/content/docs/en/run.md +++ b/apps/docs/content/docs/en/run.md @@ -1,86 +1,104 @@ --- title: one run -description: Inject project env vars into any command and execute it from the resolved project directory. +description: Discover and execute workspace tasks with mise. --- -`one run` behaves like `infisical run` / `dotenv run`: it resolves a project, loads env vars from the workspace env provider, injects them into a child process, and runs the command you pass. +One selects projects and freezes their environment variables. mise schedules the task graph. Node projects define commands in `package.json` scripts and run them with pnpm; Go projects define them in Taskfile and run them with Task. + +```sh +one run # List tasks without executing them +one run --list -p web -o json +one run build # Build the workspace and local dependencies +one run build -p web -p api +one run test -p api -- -run TestHandler +one run check --env prod +one run ci +one run build --dry-run -o json +``` -## Usage +## Selection and arguments -```bash -one run [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...] -``` +Without `-p`, a task selects the workspace aggregate. Repeat `-p` to select project names or workspace-relative paths. Everything after `--` is forwarded to one selected task; forwarding arguments to multiple projects is rejected. `one build web` shares the execution path of `one run build -p web`. + +`one exec web -- pnpm add axios` runs an arbitrary command. It does not invoke a task graph or install application dependencies. + +## Configuration -You can omit `--`, but scripts should keep it so child flags are not parsed as One CLI flags. +One maintains root and project `.mise/conf.d/one.toml` fragments. They contain tool pins, task adapters, aggregates, and local Node build dependencies. Edit `mise.toml` for your overrides; generated fragments have ownership checks and cannot be edited in place. Running a task refreshes generated configuration. Root `build`, `check`, and `test` aggregate only available project tasks; `ci` combines them. -## Options +Project tasks use canonical names such as `//apps/web:build`. Local Node dependencies with a build task run before dependent build, check, test, typecheck, and dev tasks. Go resolves module dependencies through `go.work` and its compiler. New Node workspaces use pnpm exclusively. -| option | purpose | -|---|---| -| `-p`, `--project ` | select a project; without it, One CLI infers from cwd | -| `--env-provider dotenv|infisical` | force a provider instead of using the workspace manifest | -| `--env ` | use a specific environment | -| `-o`, `--output ` | affects only One CLI output; child stdout/stderr pass through | +List and dry-run use static configuration and never execute mise, install tools, read secrets, or write files. Dynamic task expressions and parameterized dependencies cannot be previewed. Actual runs also read mise's effective task catalogue. Native mise tasks with their own commands use mise's environment; generated project adapters use One's project environment. -## Interactive Mode +## Define a task -`one run` has no wizard. It only resolves the project, environment, and child command from arguments. Keep the `--` separator in scripts because the child command can have its own flags. +Keep project commands in `package.json` scripts or a Taskfile. For example, adding `"docs:build": "typedoc"` makes `one run docs:build -p lib` available. Define workspace tasks in the root `mise.toml`: -## Examples +```toml +[tasks.verify] +depends = ["//apps/web:check", "//services/api:test"] -```bash -one run -- npm test -one run -p web -- npm run build -one run -p apps/web -- pnpm lint -one run --env-provider dotenv -- npm test -one run --env staging -- npm run e2e +[tasks.hello] +description = "Print a greeting" +run = "echo hello" ``` -The child process always runs from the resolved project directory, so commands find that project's `package.json`, `Taskfile.yml`, or Go module files. +Run the aggregate with `one run verify`, or the custom command with `one run hello`. You can also put executable scripts in `.mise/tasks/`, or load a separate task file with `[task_config] includes = ["tasks.toml"]`. -## PATH and env +Inspect native configuration with `one mise tasks ls --all --local` and `one mise tasks info //apps/web:build --json`. Custom native tasks can run directly with `mise run hello`; generated project tasks need `one run` to prepare their environment context. -`one run` merges loaded variables into the child environment, overriding same-name shell variables. It also prepends: +## Cache -```text -/node_modules/.bin -/node_modules/.bin +Generated configuration enables mise's experimental features. Known template **build** tasks receive `sources`, `outputs`, and an environment fingerprint. Other tasks stay uncached until they have an explicit cache contract. Changing a template build script or build configuration disables automatic caching; declare the actual inputs and outputs in the project's `mise.toml` to enable it again. Cache storage and artifact restoration belong to mise. + +```sh +one run build --cache local-only +one run build --cache off --force +one run build --cache read-only +one run build --concurrency 4 ``` -This lets pnpm / turbo workspaces invoke `vite`, `next`, `astro`, and similar binaries directly. +`--cache off --force` ensures execution even if source freshness checks would skip a task. Cache modes also include `read-write` and `write-only`; remote access requires your own mise cache backend configuration. One does not provision a remote service. -## Env provider +To opt a generated task into caching, declare its complete inputs and outputs in the project's `mise.toml`, including One's fingerprint command: -| provider | behavior | -|---|---| -| `dotenv` | read project `.env` overlays | -| `infisical` | fetch env vars from Infisical | -| empty | use the provider recorded in the workspace manifest | +```toml +[tasks.build] +sources = ["src/**/*", "package.json", "tsconfig.json", "../../pnpm-lock.yaml"] +outputs = ["dist"] +cache = { enabled = true, env = ["NODE_ENV"], command_inputs = ['one __task-input --project "web" --task "build"'] } +``` -`--env-provider infisical` requires browser login with `one login`. Use `--env-provider dotenv` for offline local runs. +`outputs = []` is appropriate for a deterministic task that only verifies inputs. Declare every upstream source, external file, configuration value, and compiler input that affects a custom task. Outputs must stay inside the task directory and must not overlap another selected task's outputs. Use `--cache off --force` while checking a new cache declaration. -## Common errors +One loads each managed project's environment once per invocation, before cache lookup, into a temporary context. The fingerprint and command consume that same snapshot. Environment values remain out of generated TOML and structured results. Every injected project variable contributes to the fingerprint; authentication credentials used only to fetch variables do not. Directly invoking a generated adapter through `mise run` requires that context, so use `one run` for these tasks. -| code | fix | -|---|---| -| `NOT_ONE_PROJECT` | run inside a workspace or project directory | -| `SUBPROJECT_NOT_FOUND` | pass a manifest `name` or `relativeDir` to `-p` | -| `RUN_COMMAND_NOT_FOUND` | check PATH, project `node_modules/.bin`, and workspace `node_modules/.bin` | -| `ENV_FILE_NOT_FOUND` | create a project `.env` or use `--env-provider infisical` | -| `INFISICAL_AUTH_MISSING` | run `one login` | +## Terminals and output -## Next +Finite tasks use streamed mise output. `--ui raw` preserves native terminal input and disables caching. `--ui tui` is available for development through `one run dev` / `one dev`; finite tasks report that it is unavailable. Use a dedicated `test:watch` task or raw mode for interactive tests. Development runs finite upstream builds before starting the existing supervisor. -- [Run with env vars](/en/tutorials/run-passthrough/) -- [one env](/en/docs/env-vars/) -- [one dev](/en/docs/dev/) +JSON/YAML previews use `one-cli/task-plan/v1`; execution uses `one-cli/task-result/v1`. Child logs go to stderr in structured mode. The result reports the overall status and exit code. Per-task status remains `unknown` because the selected mise version does not provide reliable structured completion events. One does not infer cache hits from console text. +## GitHub Actions -## Global credentials +Keep your workflow in the repository and invoke the same tasks as local development. Once One is installed, a minimal job can use: -```bash -one run --global --env dev --path /oss --keys OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET -- upload-assets -one run --global --env dev --path /oss --dry-run -- upload-assets +```yaml +- uses: actions/checkout@v7 +- uses: jdx/mise-action@v4 + with: + version: 2026.9.7 + experimental: true +- uses: actions/cache@v4 + with: + path: .cache/mise-task-artifacts + key: tasks-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml', '**/go.sum', '**/mise.toml') }}-${{ github.sha }} + restore-keys: tasks-${{ runner.os }}-${{ runner.arch }}- +- run: one run ci --ui stream + env: + MISE_TASK_CACHE_DIR: ${{ github.workspace }}/.cache/mise-task-artifacts ``` -Environment and folder must be explicit. Only that folder is read; `--keys` fetches only selected variables. Dry-run does not read credentials. Global mode does not load project environments or implicitly resolve repository binaries. It preserves the current directory. Exact-value output masking is best effort, not a sandbox. +The cache directory must be excluded from task sources and version control. Cache keys isolate runner platforms; mise validates individual artifact keys. For workflows reading secrets, apply the repository's normal permissions and branch trust rules. + +See [mise task caching](https://mise.jdx.dev/tasks/caching.html) and [mise configuration](https://mise.jdx.dev/tasks/task-configuration.html) for native options. diff --git a/apps/docs/content/docs/en/skills.md b/apps/docs/content/docs/en/skills.md deleted file mode 100644 index 09cc06f..0000000 --- a/apps/docs/content/docs/en/skills.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: one skills install -description: Install the minimal One CLI skill for your coding agent. ---- - -The bundled `one-cli` skill has two responsibilities: follow [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention), and discover supported workspace operations through the installed CLI's help. Command lists, flags, templates, and manifest schemas stay with the CLI rather than being copied into the skill. - -```bash -one skills install -one skills install --agent claude-code --agent codex -one skills install --yes -o json -``` - -## Target selection - -- `--agent ` / `-a ` selects a target explicitly, even before that agent has created its configuration directory. Repeat the option for multiple agents. -- With no explicit targets, an interactive terminal offers detected agents, all preselected. -- `--yes` / `-y` or non-interactive execution installs into all detected agents without prompting. -- If no targets are detected, the command fails and asks for an explicit `--agent`. -- `one skills` and `one skills install --help` only display help. The latter lists every supported agent ID. - -Detection uses user configuration directories. Agent IDs from the previous installer remain supported. `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, and `VIBE_HOME` override their respective default roots when set to absolute directories. - -## Installation - -Installation copies the embedded `one-cli/SKILL.md` into the selected agents' user skills directories. It works offline and from any directory. Duplicate targets sharing a directory are written once. Success output lists the destination directories; the skill is in `one-cli/SKILL.md` below each destination. - -Repeating installation replaces that `one-cli` directory, including old reference files. Other skills stay intact. A legacy `one-cli` symlink is replaced with a direct copy without changing its shared store or other agents' links. If an installation fails after some targets succeed, the error's `context.installed_to` lists completed destinations; rerun the command after fixing the reported problem. - -After installation, load the installed skill. Restart the agent session if it does not discover new skills automatically. - -## Generated workspaces - -`one create`, including preset creation, writes a short root `AGENTS.md`: - -```markdown -# Development - -Use the `one-cli` skill when developing this workspace. -If it is not installed, run `one skills install` first. -``` - -Creation does not install skills into your home directory. `one add` preserves existing instructions. For an existing workspace, install the skill and add the same guidance to its `AGENTS.md`. - -## Updates and output - -Ordinary CLI upgrades do not require reinstalling this skill: it directs agents to `one --help`, `one help --all`, and the relevant command help. Reinstall only when the skill's guidance itself changes or when configuring another agent. - -Structured output uses `one-cli/skills-install/v1`, with `status`, `targets`, `installed_to`, and `skill_count` (one). Use the usual `-o json`, `-o yaml`, or `-o text` option. Installation failures use `SKILLS_INSTALL_FAILED`. diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md index 371d156..6497bdc 100644 --- a/apps/docs/content/docs/zh/add.md +++ b/apps/docs/content/docs/zh/add.md @@ -127,5 +127,4 @@ one add nestjs-api --name user-api --yes -o json | jq - 检查 `one.manifest.json#projects[]` 确认项目登记 - Agent 文档和本地开发配置会由 `one add` 同步 - 下一步运行 `one dev ` 开发,使用 `one build ` 构建 -- 如需持续集成,单独运行 `one ci enable ` 生成 GitHub Actions 工作流 -- `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one run -- go mod tidy`。 +- `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one exec -- go mod tidy`。 diff --git a/apps/docs/content/docs/zh/ai-native.md b/apps/docs/content/docs/zh/ai-native.md index 093d5d6..9127f51 100644 --- a/apps/docs/content/docs/zh/ai-native.md +++ b/apps/docs/content/docs/zh/ai-native.md @@ -97,9 +97,7 @@ agent 的处理顺序应该是: ## 规则三:项目说明由团队维护 -新工作区包含简短的根 `AGENTS.md`,要求使用 `one-cli` skill;缺失时运行 `one skills install`。普通创建与 preset 创建使用同一份指引,后续 `one add` 保留团队修改。 -Skill 要求遵循 [One Workspace Convention](https://github.com/1cli-team/one-workspace-convention),并查询当前 CLI 的 help。Agent 还可以读取 `one.manifest.json` 和项目 README。Skill 安装独立于项目创建,详见 [Skills](./skills)。 ## 规则四:配置和凭据有边界 diff --git a/apps/docs/content/docs/zh/build.md b/apps/docs/content/docs/zh/build.md index 46d197a..9629cb6 100644 --- a/apps/docs/content/docs/zh/build.md +++ b/apps/docs/content/docs/zh/build.md @@ -1,43 +1,19 @@ --- title: one build -description: 构建全部项目或指定项目。 +description: 通过 mise 构建项目及其本地依赖。 --- -`one build` 自动准备工具和应用依赖,再逐个执行项目构建任务。 +`one build` 是 `one run build` 的便捷入口,共用任务规划、环境快照、mise 调用、缓存和输出 schema。 -```bash +```sh one build -one build web -one build apps/web +one build web api one build -p web --env prod -one build --dry-run -o json +one build web --dry-run -o json +one build --cache off --force +one build web --ui raw ``` -不指定项目时,构建 manifest 中所有可构建项目;从项目子目录执行也保持这个行为。项目名或工作区相对路径只选择该项目,不自动构建它依赖的其他本地项目。 +Node 从 `package.json` 读取命令,使用 pnpm;Go 从 Taskfile 读取命令。全工作区构建只包含存在 build 的项目,显式选择没有 build 的项目会报错。本地 Node 上游先于下游构建。默认并发为 1,`--concurrency 4` 可并行执行独立任务。 -## 构建命令 - -Dashboard 的项目概览会展示实际构建命令和配置来源。该字段只读;修改项目中的 `package.json` 或 `Taskfile.yml` 后,刷新页面即可看到更新。 - -- Node:读取当前 `package.json`,使用工作区包管理器(pnpm / npm / yarn / bun)执行 build 脚本。 -- Go:使用项目 `Taskfile.yml` 中的 `task build`。Go API 模板生成 `bin/server`;Go 库模板使用 `go build ./...` 编译包。旧的 Go 库可手动给 Taskfile 补上该任务。 - -Go 项目必须有 Taskfile。全量构建跳过没有 build 任务的项目,记录 `no-build-task`;显式选择这类项目时返回 `RUNTIME_TASK_NOT_FOUND`。配置损坏或必要文件缺失会在准备依赖前报错。没有任何构建任务的工作区也会报错。产物位置由项目自己的构建脚本决定。 - -## 执行顺序 - -全量构建串行执行,先构建本地 Node 依赖,再构建使用它们的项目。依赖来源为 dependencies、devDependencies、optionalDependencies,按 package.json 的 name 匹配;file: / link: 依赖按目录匹配。无依赖约束的项目保持 manifest 遍历顺序。重复包名和循环依赖在执行前报错。Go 的包依赖由 Go 工具链处理。 - -首个构建失败后停止,剩余任务记录为 `not_run`,保留失败子进程的退出码。Ctrl+C 停止当前构建及其子进程。日志带项目名前缀。 - -## 工具、依赖与环境变量 - -沿用 `one dev` 的 mise/builtin 自动选择与依赖准备方式,也覆盖没有 dev 命令的库项目。Node 依赖在工作区根目录统一安装一次。每个构建通过 `one run` 使用项目环境变量和 PATH,在项目目录执行。`--env` 指定环境,省略时使用 manifest 默认环境。 - -`--dry-run` 展示排序后的命令、目录和跳过项目,不安装工具或依赖、不读取密钥、不联网、不写文件。 - -## 输出 - -`-o json` / `-o yaml` 预览返回 `one-cli/build-plan/v1`,实际执行返回 `one-cli/build-result/v1`。过程日志写入 stderr,stdout 保持可解析。结果包含每个项目的状态、命令、耗时和退出码。依赖准备失败时返回错误,构建任务保持 `not_run`。 - -自定义命令使用 [`one run`](/zh/docs/run/)。 +Dashboard 展示项目构建命令及其来源;修改对应源文件即可改变命令。跨项目依赖和缓存规则由 mise 配置声明。完整任务管理、自定义缓存、终端模式和 Actions 示例见 [one run](/zh/docs/run/)。 diff --git a/apps/docs/content/docs/zh/ci.md b/apps/docs/content/docs/zh/ci.md deleted file mode 100644 index 282586f..0000000 --- a/apps/docs/content/docs/zh/ci.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: 持续集成 -description: 查看、启用、更新或移除可选的持续集成工作流。 ---- - -持续集成是可选能力。`one create` 和 `one add` 都不会生成工作流文件。当前版本 -支持 GitHub Actions。 - -## 用法 - -```bash -one ci -one ci enable [project] -one ci sync [project] -one ci disable [project] -``` - -`one ci` 是只读命令,会显示每个项目的当前状态和最合适的下一步命令。 - -## 命令 - -| 命令 | 行为 | -|---|---| -| `one ci` | 查看全部项目的持续集成状态 | -| `one ci enable web` | 为 `web` 生成标准工作流 | -| `one ci enable` | 为全部项目启用持续集成 | -| `one ci sync web` | 在已经启用时重新生成 `web` 的工作流 | -| `one ci sync` | 更新所有已经存在的工作流 | -| `one ci disable web` | 确认后删除 `web` 的生成工作流 | -| `one ci disable --yes` | 不询问,删除全部生成工作流 | - -拒绝停用确认或按 Ctrl-C 都属于正常取消:命令以 0 退出,文件保持不变。 - -非交互调用不会把“没有终端”视为同意删除。必须显式传入 `--yes`,否则命令返回 -`CI_DISABLE_CONFIRMATION_REQUIRED`,并保留全部工作流文件。 - -## 文件与工作区状态 - -GitHub Actions 的标准文件按项目写入 `.github/workflows/`。持续集成选择不会写入 -`one.manifest.json`,生成文件本身就是状态。因此 `enable`、`sync`、`disable` 都 -不会修改 manifest。 - -## 自动化 - -```bash -one ci -o json -one ci enable web --provider ci/github-actions -o json -one ci sync --project web -o json -one ci disable web --yes -o json -``` - -稳定 schema 是 `one-cli/ci-status/v1`、`one-cli/ci-enable/v1`、 -`one-cli/ci-sync/v1` 和 `one-cli/ci-disable/v1`。Provider ID 和 JSON 字段不会翻译。 -`--project` 为旧脚本保留,日常帮助优先展示位置参数。 - -## 错误 - -| 错误码 | 处理 | -|---|---| -| `CI_DISABLE_CONFIRMATION_REQUIRED` | 检查目标项目后,带 `--yes` 重新运行 | -| `CI_NOT_ENABLED` | 先运行 `one ci enable `,再重试 sync | -| `CI_PROVIDER_UNKNOWN` | 使用 `error.context.available_providers` 中的 ID | -| `CI_RENDER_FAILED` | 检查错误 context 中的项目和工作流路径 | - -稳定错误信封详见 [错误码](/zh/docs/error-codes/)。 diff --git a/apps/docs/content/docs/zh/cli-overview.md b/apps/docs/content/docs/zh/cli-overview.md index 5a164bd..53e6215 100644 --- a/apps/docs/content/docs/zh/cli-overview.md +++ b/apps/docs/content/docs/zh/cli-overview.md @@ -12,10 +12,12 @@ description: One CLI 日常命令和高级入口。 | `one env` | 查看和管理项目环境变量 | | `one login` / `one whoami` / `one logout` | 单账号浏览器会话 | | `one env --global` | 发现共享凭据位置与环境 | -| `one run` | 注入变量后执行命令 | +| `one exec` | 注入变量后执行命令 | | `one serve` | 打开 Dashboard | | `one locale` | 本机语言 | | `one init mise` / `one init hooks` | 工作区工具配置 | -| `one ci` / `one templates` / `one skills` | 自动化与资源 | +| `one run` | 列出和执行工作区任务 | +| `one templates` | 查看项目模板 | +| `one hk` | 执行 Git hooks 检查 | 通过 `one help --all` 发现完整命令,执行前查看对应 `--help`。Agent 无需在 Dashboard 复制执行命令,可以自主读取帮助、列出目录与变量元数据,再用明确的环境和目录执行任务。详情见[登录与共享凭据](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index c8fe8ec..bdbf1b5 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -57,7 +57,7 @@ one create my-app --yes --env-provider infisical 提交前默认只检查暂存内容,使用 `one hk fix` 显式修复。用法与自定义方式见 [`one hk`](/zh/docs/hk/)。Git 未安装或已有 hooks 配置发生冲突时,工作区仍会创建,输出的 `warnings` 会提示后续执行 `one init hooks`。 持续集成默认不配置。创建工作区不会写入 `.github/workflows/`;添加项目后如有 -需要,再显式运行 `one ci enable `。 +需要,可以手动配置调用 `one run ci` 的工作流。 ## --env-provider 语义 @@ -147,4 +147,5 @@ one dev api ## Agent 指引 -普通创建和 preset 创建都会写入简短的根 `AGENTS.md`,要求使用 `one-cli` skill,缺失时运行 `one skills install`。Skill 安装是独立的用户级操作;后续 `one add` 保留这份文件及团队自行补充的内容。 + +新工作区生成中英文 `AGENTS.md`,说明 `one run`、`one exec`、pnpm 和 Task 的分工。后续添加项目保留团队修改。 diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index d5f7c97..c8bdf2d 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -31,7 +31,7 @@ one dev [project] [--dry-run] - Go:独立模块下载固定构建列表并补充 `go.sum`;存在 `go.work` 时由 Go 按实际包依赖解析本地成员和外部依赖,按需维护 `go.work.sum`。准备过程不自动运行 `go mod tidy` 或 `go work sync`。 - 所有准备成功后才启动 supervisor。失败保留底层错误和下载缓存,可修复后重试原命令;取消时停止准备进程。 -`go.work.sum` 不替代各模块发布所需的 `go.sum`。模块声明需要修复时,可显式运行 `one run api -- go mod tidy`。`one run` 保持直接执行命令,不自动安装应用依赖。 +`go.work.sum` 不替代各模块发布所需的 `go.sum`。模块声明需要修复时,可显式运行 `one exec api -- go mod tidy`。`one exec` 保持直接执行命令,不自动安装应用依赖。 ## 运行方式 @@ -45,7 +45,7 @@ one dev apps/web --dry-run ## 日志滚动与窗口缩放 -在 Unix 交互式终端中运行多个任务时会进入 TUI。单项目也可通过 `one dev web --ui=tui` 打开;`one build --ui=tui` 使用相同的日志操作。 +在 Unix 交互式终端中运行多个任务时会进入 TUI。单项目也可通过 `one dev web --ui=tui` 打开。有限构建任务使用 mise 流式输出。 | 操作 | 行为 | |---|---| @@ -76,5 +76,7 @@ one dev apps/web --dry-run ## 进一步阅读 - [本地开发编排](/zh/tutorials/dev-local/) — 内置 supervisor 的完整流程 -- [`one run`](/zh/docs/run/) — 只给单条命令注入环境变量 +- [`one exec`](/zh/docs/exec/) — 只给单条命令注入环境变量 - [manifest](/zh/docs/manifest/) — 项目列表的来源 + +`one run dev` 使用相同的 supervisor。开发启动前,本地上游包的有限构建由 mise 调度执行。 diff --git a/apps/docs/content/docs/zh/env-vars.md b/apps/docs/content/docs/zh/env-vars.md index bbb24b7..117334d 100644 --- a/apps/docs/content/docs/zh/env-vars.md +++ b/apps/docs/content/docs/zh/env-vars.md @@ -247,4 +247,4 @@ Workspace 级 env 后端写在 `one.manifest.json#domains.env`,环境列表写 ## 共享凭据 -共享凭据独立于工作区。使用 `one env bind --global` 选择存放位置,`one env list --global --env dev --path /` 浏览元数据,`one run --global --env dev --path /folder -- command` 注入明确范围的变量。完整的命令和安全边界见[登录与共享凭据](/zh/docs/login/)。 +共享凭据独立于工作区。使用 `one env bind --global` 选择存放位置,`one env list --global --env dev --path /` 浏览元数据,`one exec --global --env dev --path /folder -- command` 注入明确范围的变量。完整的命令和安全边界见[登录与共享凭据](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index 6f3f985..47423f9 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -40,18 +40,6 @@ import { Callout } from "fumadocs-ui/components/callout"; 下面按命令域分组列出所有 code。 -## Agent skill 安装 - -内置 one-cli skill 的目标选择与用户级目录安装错误。 - -### `SKILLS_INSTALL_FAILED` - -The bundled one-cli skill could not be installed into the selected agents. - -**Remediation**: - -- `inspect-skill-install` — Check the target agent and directory permissions. Completed targets are listed in context.installed_to; retrying is safe.
运行:`one skills install --help` - ## 通用 / 生命周期 命令本身的失败、用户取消、内部序列化错误。 @@ -227,30 +215,6 @@ Workspace 后置同步失败:写入 manifest 后某个后端 sync 回滚或失 Profile 解析、CI 产物生成和本地开发过程中的问题。 -### `CI_DISABLE_CONFIRMATION_REQUIRED` - -A non-interactive CI disable requires explicit --yes confirmation. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `CI_NOT_ENABLED` - -The selected project does not have a generated CI workflow. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `CI_PROVIDER_UNKNOWN` - -The requested CI provider is not implemented by this build. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `CI_RENDER_FAILED` - -The selected CI provider returned an error while rendering the workflow. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - ### `LOCAL_ORCH_PORT_CONFLICT` Two projects requested the same dev port and the dev runner could not auto-allocate a free one. @@ -452,11 +416,11 @@ Backend's Invoke method returned an error. ### `BACKEND_NOT_ENABLED` -A domain command was invoked in a workspace where that domain is not configured. +The requested environment backend is not configured. **Remediation**: -- `configure-domain` — 在 one.manifest.json 的 domains 块中配置该域(domains.env.kind / projects[].domains.container 等),或选用声明它的模板再 one add +- `configure-domain` — Configure the environment backend with one env bind. ### `BACKEND_VERB_NOT_SUPPORTED` @@ -474,7 +438,7 @@ Node dependencies required for local development are not installed. ### `DOMAIN_INVALID` -Domain name is not one of the recognised domains (container / deploy / dev / ci / env). +The requested configuration domain is not supported. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 @@ -494,7 +458,7 @@ Domain is recognised but this build has no backend implementation for it. ### `DOMAIN_REQUIRED` -A domain (container / deploy / dev / ci / env) is required but its section is missing in one.manifest.json. +The environment configuration is missing in one.manifest.json. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 @@ -577,29 +541,29 @@ The project does not provide the requested runtime task. ### `RUN_COMMAND_NOT_FOUND` -one run could not locate the requested executable on PATH. +one exec could not locate the requested executable on PATH. **Remediation**: - `check-spelling` — 确认命令名拼写正确 -- `use-package-runner` — 对于 npm script,使用包管理器调用
运行:`one run -- npm run \x1b[0m\x1b]8;;https://evil.example\x07link\x1b]8;;\x07"; + const { container } = render(
{renderConsole(raw)}
); + expect(container.querySelector("script,a")).toBeNull(); + expect(container.textContent).toBe("link"); + expect(consoleText(raw)).toBe("link"); + }); +}); diff --git a/apps/dashboard/src/features/services/ServicePanel.tsx b/apps/dashboard/src/features/services/ServicePanel.tsx new file mode 100644 index 0000000..076c32b --- /dev/null +++ b/apps/dashboard/src/features/services/ServicePanel.tsx @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Copy, + ExternalLink, + LoaderCircle, + Pause, + Play, + RotateCw, + Search, + Square, + Terminal, +} from "lucide-react"; +import { toast } from "sonner"; +import { controlService, watchService } from "@/api/services"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { + manifestDraftKey, + useManifestDraftStore, +} from "@/features/manifest-draft/manifest-draft-store"; +import { consoleText, renderConsole } from "@/features/services/console"; +import type { DevService, ProjectSettings } from "@/types/api"; + +export function mergeService(previous: DevService | undefined, next: DevService): DevService { + const retained = + previous?.id === next.id ? previous.logs.filter((line) => line.seq >= next.firstSeq) : []; + const last = retained.at(-1)?.seq ?? 0; + return { + ...next, + logs: [...retained, ...next.logs.filter((line) => line.seq > last)].slice(-2048), + }; +} +export function ServicePanel({ + project, + environment, + entryId, + readOnly, +}: { + project: ProjectSettings; + environment: string; + entryId?: string; + readOnly?: boolean; +}) { + const { t } = useTranslation(); + const [state, setState] = useState(); + const [connected, setConnected] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + const [follow, setFollow] = useState(true); + const viewport = useRef(null); + const draft = useManifestDraftStore((store) => store.drafts[manifestDraftKey(entryId)]); + useEffect( + () => + watchService( + project.name, + entryId, + (next) => setState((previous) => mergeService(previous, next)), + setConnected, + ), + [entryId, project.name], + ); + const active = state && ["preparing", "running", "stopping"].includes(state.status); + const busy = pending || state?.status === "stopping"; + const hasDev = + !!project.devCommand || + project.tasks?.entries.some((task) => task.name === "dev" || task.name.endsWith(":dev")); + const raw = state?.logs.map((line) => line.text).join("") ?? ""; + const text = useMemo(() => consoleText(raw), [raw]); + const visible = useMemo( + () => + query + ? text + .split("\n") + .filter((line) => line.toLocaleLowerCase().includes(query.toLocaleLowerCase())) + .join("\n") + : raw, + [query, text, raw], + ); + const consoleContent = useMemo(() => renderConsole(visible), [visible]); + useEffect(() => { + if (follow && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight; + }, [visible, follow]); + async function control(action: "start" | "stop" | "restart") { + setPending(true); + setError(""); + try { + await controlService(project.name, entryId, action, environment); + } catch (failure) { + setError((failure as { message?: string }).message ?? t("services.actionFailed")); + } finally { + setPending(false); + } + } + async function copy() { + try { + await navigator.clipboard.writeText(query ? visible : text); + toast.success(t("services.copied")); + } catch { + toast.error(t("services.copyFailed")); + } + } + const status = state?.status ?? "stopped"; + return ( +
+
+
+
+
+

{t("services.title")}

+ + {status === "preparing" || status === "stopping" ? ( + + ) : null} + {t(`services.status.${status}`)} + +
+

{t("services.description")}

+

+ one dev -p {project.name} --env {active ? state.environment : environment} +

+
+
+ {active ? ( + <> + + + + ) : ( + + )} +
+
+ {!!draft &&

{t("services.saveFirst")}

} + {!hasDev &&

{t("services.noDev")}

} + {active && state.environment !== environment && ( +

+ {t("services.environmentMismatch", { + environment: state.environment, + next: environment, + })} +

+ )} + {state?.exitCode !== undefined && !active && ( +

+ {t("services.exitCode", { code: state.exitCode })} +

+ )} + {error && ( + + {error} + + )} +
+ {state?.endpoints.length ? ( + state.endpoints.map((endpoint) => ( +
+
+

{endpoint.url}

+

+ {t( + endpoint.reachable && active ? "services.reachable" : "services.unreachable", + )} +

+
+ {endpoint.reachable && active ? ( + + ) : ( + + )} +
+ )) + ) : ( +

+ {t(active ? "services.waitingURL" : "services.urlHint")} +

+ )} +
+
+
+
+
+ +

{t("services.console")}

+ + {t(connected ? "services.live" : "services.reconnecting")} + +
+
+ + + + + setQuery(event.target.value)} + placeholder={t("services.search")} + aria-label={t("services.search")} + /> + + + +
+
+
+					{raw
+						? visible
+							? consoleContent
+							: t("services.noMatches")
+						: t(active ? "services.waitingLogs" : "services.emptyLogs")}
+				
+

+ {t(state && state.firstSeq > 1 ? "services.truncated" : "services.consoleHint")} +

+
+
+ ); +} diff --git a/apps/dashboard/src/features/services/console.tsx b/apps/dashboard/src/features/services/console.tsx new file mode 100644 index 0000000..17c9495 --- /dev/null +++ b/apps/dashboard/src/features/services/console.tsx @@ -0,0 +1,60 @@ +/* eslint-disable no-control-regex -- Terminal output contains ANSI control bytes. */ +import type { ReactNode } from "react"; + +// Only text and SGR colors are rendered. OSC links, HTML and terminal commands +// are never interpreted. Joining retained chunks also handles split sequences. +const controls = + /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[a-ln-zA-Z]|[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g; +export function consoleText(text: string) { + return text + .replace(controls, "") + .replace(/\x1b\[[0-9;]*m/g, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); +} +const colors: Record = { + 31: "text-error-foreground", + 32: "text-success-foreground", + 33: "text-warning-foreground", + 34: "text-primary-text", + 35: "text-chart-5", + 36: "text-chart-2", + 90: "text-muted-foreground", + 91: "text-error-foreground", + 92: "text-success-foreground", + 93: "text-warning-foreground", + 94: "text-primary-text", + 95: "text-chart-5", + 96: "text-chart-2", +}; +export function renderConsole(text: string): ReactNode[] { + const clean = text.replace(controls, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const parts = clean.split(/(\x1b\[[0-9;]*m)/g); + let color = ""; + let bold = false; + return parts.map((part, index) => { + if (part.startsWith("\x1b[")) { + const codes = part.slice(2, -1).split(";").map(Number); + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + if (code === 38 || code === 48) { + i += codes[i + 1] === 2 ? 4 : 2; + continue; + } + if (code === 0) { + color = ""; + bold = false; + } else if (code === 1) bold = true; + else if (code === 22) bold = false; + else if (code === 39) color = ""; + else if (colors[code]) color = colors[code]; + } + return null; + } + return ( + + {part} + + ); + }); +} diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index b8528f9..9520735 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -325,7 +325,8 @@ "tabs": { "label": "Project configuration sections", "overview": "Overview", - "environment": "Environment" + "environment": "Environment", + "runtime": "Run" }, "loadFailed": "Unable to load project configuration", "retry": "Retry", @@ -365,7 +366,9 @@ "taskCacheOn": "Artifact caching is configured.", "taskCacheOff": "Artifact caching is not configured.", "taskDepends": "Dependencies: {{tasks}}", - "taskOutputs": "Outputs: {{outputs}}" + "taskOutputs": "Outputs: {{outputs}}", + "devURL": "Development URL (optional)", + "devURLHint": "Leave empty to detect from logs. Use a local HTTP(S) URL without credentials or query parameters." }, "environment": { "title": "Environment configuration", @@ -388,7 +391,10 @@ "none": "No profile currently resolves", "manage": "Manage in Settings" }, - "draftHint": "Changes are kept in a draft. Review and save them to apply." + "draftHint": "Changes are kept in a draft. Review and save them to apply.", + "runtime": { + "title": "Service and console" + } }, "secrets": { "title": "Infisical secrets", @@ -787,5 +793,44 @@ "feature3": "Logging, local settings, and updater modules" } } + }, + "services": { + "title": "Development service", + "description": "Start the project, view its output, and open it for testing.", + "start": "Start service", + "stop": "Stop", + "restart": "Restart", + "open": "Open", + "console": "Console", + "live": "Connected", + "reconnecting": "Connecting…", + "search": "Search logs", + "pause": "Pause scrolling", + "follow": "Follow logs", + "copy": "Copy", + "copied": "Logs copied", + "copyFailed": "Could not copy. Select and copy text in the console.", + "actionFailed": "Action failed. Try again.", + "saveFirst": "Save or discard pending configuration changes before starting or restarting.", + "noDev": "No dev task is configured. Set a dev command in Overview or add a dev task to the project’s mise.toml.", + "environmentMismatch": "This service uses {{environment}}. Restart to use the selected {{next}} environment.", + "exitCode": "Exit code: {{code}}", + "reachable": "Local port is reachable", + "unreachable": "Local port is not reachable", + "waitingURL": "Waiting for a local URL in the output. Services without a web page can still run.", + "urlHint": "Local URLs are detected after startup. You can also set one in Overview.", + "waitingLogs": "Preparing the runtime. Waiting for output…", + "emptyLogs": "Start a service to see its output here.", + "noMatches": "No matching logs.", + "consoleHint": "Logs are redacted; up to 256 KiB is retained. Closing this page keeps services running; closing one serve stops them.", + "truncated": "Older logs were removed. Only recent output is shown.", + "status": { + "preparing": "Preparing", + "running": "Running", + "stopping": "Stopping", + "stopped": "Stopped", + "exited": "Exited", + "failed": "Failed" + } } } diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index cfbd75a..6caa5db 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -325,7 +325,8 @@ "tabs": { "label": "项目配置分区", "overview": "概览", - "environment": "环境" + "environment": "环境", + "runtime": "运行" }, "loadFailed": "无法加载项目配置", "retry": "重试", @@ -365,7 +366,9 @@ "taskCacheOn": "已配置产物缓存。", "taskCacheOff": "未配置产物缓存。", "taskDepends": "依赖:{{tasks}}", - "taskOutputs": "产物:{{outputs}}" + "taskOutputs": "产物:{{outputs}}", + "devURL": "开发服务访问地址(可选)", + "devURLHint": "留空时从日志自动识别。仅支持不含凭据及查询参数的本机 HTTP(S) 地址。" }, "environment": { "title": "环境配置", @@ -388,7 +391,10 @@ "none": "尚未解析到 Profile", "manage": "前往设置管理" }, - "draftHint": "修改将暂存为草稿,预览并保存后生效。" + "draftHint": "修改将暂存为草稿,预览并保存后生效。", + "runtime": { + "title": "运行与控制台" + } }, "secrets": { "title": "Infisical 密钥", @@ -787,5 +793,44 @@ "feature3": "日志、本地设置与更新器模块" } } + }, + "services": { + "title": "开发服务", + "description": "在这里启动项目,查看输出并打开页面进行测试。", + "start": "启动服务", + "stop": "停止", + "restart": "重启", + "open": "打开访问", + "console": "控制台", + "live": "已连接", + "reconnecting": "正在连接…", + "search": "搜索日志", + "pause": "暂停滚动", + "follow": "跟随日志", + "copy": "复制", + "copied": "日志已复制", + "copyFailed": "无法复制,请在控制台选择文本后复制。", + "actionFailed": "操作失败,请重试。", + "saveFirst": "有未保存的配置,请先保存或丢弃,再启动或重启服务。", + "noDev": "尚未配置 dev 任务。请在概览中设置开发命令,或在项目的 mise.toml 中添加 dev 任务。", + "environmentMismatch": "当前服务使用 {{environment}} 环境。重启后将使用所选的 {{next}} 环境。", + "exitCode": "退出码:{{code}}", + "reachable": "本机端口可连接", + "unreachable": "本机端口尚不可连接", + "waitingURL": "等待服务输出本机访问地址。没有页面的服务也可正常运行。", + "urlHint": "启动后自动识别本机地址,也可在概览中设置访问地址。", + "waitingLogs": "正在准备运行环境,等待输出…", + "emptyLogs": "启动服务后,运行日志会显示在这里。", + "noMatches": "没有匹配的日志。", + "consoleHint": "日志已脱敏,最多保留最近 256 KiB。关闭页面不影响服务;关闭 one serve 会停止服务。", + "truncated": "较早的日志已移除,仅显示最近的输出。", + "status": { + "preparing": "准备中", + "running": "运行中", + "stopping": "停止中", + "stopped": "已停止", + "exited": "已退出", + "failed": "运行失败" + } } } diff --git a/apps/dashboard/src/pages/Overview.test.tsx b/apps/dashboard/src/pages/Overview.test.tsx index 2b45eee..84e3b71 100644 --- a/apps/dashboard/src/pages/Overview.test.tsx +++ b/apps/dashboard/src/pages/Overview.test.tsx @@ -24,7 +24,9 @@ import type { ProjectSettingsResponse, } from "@/types/api"; -const server = setupServer(); +const server = setupServer( + http.get(/\/api\/workspaces?(?:\/[^/]+)?\/services$/, () => HttpResponse.json({ services: [] })), +); const catalogBackends: BackendSpec[] = [ { @@ -398,7 +400,7 @@ describe("workspace overview Profile-only configuration", () => { await user.type(within(inspector).getByLabelText("Build version"), "2.0.0"); expect( useManifestDraftStore.getState().drafts[manifestDraftKey()]?.changes.web?.general, - ).toEqual({ buildVersion: "2.0.0", devCommand: "pnpm dev" }); + ).toEqual({ buildVersion: "2.0.0", devCommand: "pnpm dev", devURL: "" }); expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); expect(receivedEnvironment).toBe("dev"); }); diff --git a/apps/dashboard/src/router/routes.test.tsx b/apps/dashboard/src/router/routes.test.tsx index 3cce2e6..68d89ee 100644 --- a/apps/dashboard/src/router/routes.test.tsx +++ b/apps/dashboard/src/router/routes.test.tsx @@ -18,6 +18,7 @@ import type { } from "@/types/api"; const server = setupServer( + http.get(/\/api\/workspaces?(?:\/[^/]+)?\/services$/, () => HttpResponse.json({ services: [] })), http.get("http://localhost/api/session", () => HttpResponse.json({ session: { loggedIn: false, expired: false } }), ), diff --git a/apps/dashboard/src/types/api.ts b/apps/dashboard/src/types/api.ts index b8f4e07..2c27854 100644 --- a/apps/dashboard/src/types/api.ts +++ b/apps/dashboard/src/types/api.ts @@ -169,6 +169,7 @@ export interface ProjectSettings { packageManager?: string; buildVersion?: string; devCommand?: string; + devURL?: string; build?: { command?: string; source?: string; @@ -202,6 +203,7 @@ export interface ProjectSettingsResponse { export interface ProjectGeneralPatch { buildVersion: string; devCommand: string; + devURL?: string; } export interface ProjectEnvironmentPatch { @@ -285,3 +287,18 @@ export interface WorkspaceEnvironmentSettings { projectName: string; siteUrl: string; } + +export type ServiceStatus = "preparing" | "running" | "stopping" | "stopped" | "exited" | "failed"; +export interface DevService { + id: string; + project: string; + environment: string; + status: ServiceStatus; + startedAt: string; + finishedAt?: string; + exitCode?: number; + endpoints: Array<{ url: string; reachable: boolean }>; + logs: Array<{ seq: number; text: string }>; + firstSeq: number; + nextSeq: number; +} diff --git a/apps/docs/content/docs/en/manifest.md b/apps/docs/content/docs/en/manifest.md index e4bba27..670420d 100644 --- a/apps/docs/content/docs/en/manifest.md +++ b/apps/docs/content/docs/en/manifest.md @@ -71,7 +71,7 @@ The real file is strict JSON; comments and unknown fields are rejected. | `env` | Optional Infisical binding: `siteUrl`, `projectId`, `projectName`, `rootPath`, and `keys` | | `projects[]` | Project names, paths, templates, toolchains, optional `packageManager` and `buildVersion` | | `projects[].env` | Environment overrides: `path`, `inherits`, `disabled`, and declared key names; inherits the workspace backend | -| `projects[].dev` | The `command` executed by `one dev` | +| `projects[].dev` | The `command` executed by `one dev`, plus an optional local Dashboard access `url` | For Infisical, `env` can contain `projectId`, `projectName`, `rootPath`, and `keys`. Key values and local Profile names never belong in the manifest. Keep credentials in the system keyring and values in Infisical. diff --git a/apps/docs/content/docs/en/serve.md b/apps/docs/content/docs/en/serve.md index 874e76a..bc152d4 100644 --- a/apps/docs/content/docs/en/serve.md +++ b/apps/docs/content/docs/en/serve.md @@ -15,3 +15,13 @@ Shared credentials can initialize a default location, create a Secret Manager pr Workspace overview shows each build command and its configuration source. Workspace Infisical project bindings and project configuration use one reviewed Manifest draft. Remote values never enter the Manifest draft or repository. Browsing an environment does not change the default. See [login and local settings](/en/docs/login/) for CLI usage and security boundaries. + +## Development services and console + +Run `one serve` in a terminal, then open a project's **Run** tab to start, stop, or restart its development service. Startup uses the effective `one dev` task, tool and dependency preparation, and Infisical variables for the selected environment. Each project has one instance; different projects can run independently. Save or discard pending Manifest drafts first. + +The console streams output with known secret values redacted, with ANSI colors, search, copy, and paused scrolling. It retains up to 256 KiB / 2,048 chunks. Refreshing reconnects; closing the page leaves services running. Interactive or raw tasks still need an external terminal; this console does not accept command input. + +Local HTTP(S) addresses are detected from logs. An optional URL can also be set in Overview (`projects[].dev.url`), without credentials, query parameters, or fragments. Access links become available when the port is reachable; this is a connectivity check, not an application health check. Services without a web page can run too. + +The environment selector affects the next start or restart; existing instances retain their environment. Stopping cleans up the process tree. Ctrl-C stops the Dashboard and every service it started. State and logs are held in memory; restarting the Dashboard does not restore services. diff --git a/apps/docs/content/docs/zh/manifest.md b/apps/docs/content/docs/zh/manifest.md index b75c1f6..97478c2 100644 --- a/apps/docs/content/docs/zh/manifest.md +++ b/apps/docs/content/docs/zh/manifest.md @@ -71,7 +71,7 @@ description: 工作区的项目登记表、环境变量来源和本地开发配 | `env` | 可选 Infisical 绑定:`siteUrl`、`projectId`、`projectName`、`rootPath` 和 `keys` | | `projects[]` | 项目名称、路径、模板、工具链,可选的 `packageManager` 和 `buildVersion` | | `projects[].env` | 项目覆盖项:`path`、`inherits`、`disabled` 和变量名 `keys`;后端继承工作区 | -| `projects[].dev` | `one dev` 执行的 `command` | +| `projects[].dev` | `one dev` 执行的 `command`,及 Dashboard 可选本机访问地址 `url` | Infisical 的 `env` 可以包含 `projectId`、`projectName`、`rootPath` 和 `keys`。Manifest 不保存变量值或本机 Profile 名;凭据保存在系统 keyring,变量值交给 Infisical。 diff --git a/apps/docs/content/docs/zh/serve.md b/apps/docs/content/docs/zh/serve.md index 9364200..a42f1cf 100644 --- a/apps/docs/content/docs/zh/serve.md +++ b/apps/docs/content/docs/zh/serve.md @@ -15,3 +15,13 @@ one serve --port 0 --open=false 工作区概览展示项目构建命令和来源。工作区环境选择已有 Infisical 项目,项目配置与工作区绑定通过统一 Manifest 草稿审阅和保存;远端变量不进入草稿或仓库。浏览环境不会改变默认环境。 安全限制及 CLI 用法见[登录与本机设置](/zh/docs/login/)。 + +## 开发服务与控制台 + +通过终端运行 `one serve`,进入项目的「运行」页即可启动、停止或重启开发服务。启动沿用 `one dev` 的有效 dev 任务、工具与依赖准备,以及当前环境的 Infisical 变量注入。同一项目只能运行一个实例;不同项目可以分别启动。未保存的 Manifest 草稿需要先保存或丢弃。 + +控制台持续显示经过已知密钥值脱敏的日志,支持 ANSI 颜色、搜索、复制和暂停滚动,最多保留最近 256 KiB / 2,048 段输出。刷新页面会重新连接;关闭页面不会停止服务。交互式或 raw 任务仍需外部终端,控制台不接收命令输入。 + +页面会从日志中识别本机 HTTP(S) 地址,也可以在概览填写可选的访问地址(`projects[].dev.url`)。地址不接受凭据、查询参数和片段。端口可连接后出现访问入口;这只表示端口可连接,不代表应用健康检查成功。无 Web 页面的后台服务也能运行。 + +环境选择只影响下次启动或重启,当前实例保留原来的环境。停止服务会清理其进程树;按 Ctrl-C 关闭 `one serve` 会停止它启动的全部服务。运行状态和日志只保存在内存中,重新启动 Dashboard 后不会恢复服务。 diff --git a/packages/cli/internal/application/manifest/service.go b/packages/cli/internal/application/manifest/service.go index a5dbe6f..32d1899 100644 --- a/packages/cli/internal/application/manifest/service.go +++ b/packages/cli/internal/application/manifest/service.go @@ -11,6 +11,7 @@ import ( catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/localurl" ) const ( @@ -48,8 +49,9 @@ func (e *ManifestConflict) Error() string { func (e *ManifestConflict) Unwrap() error { return ErrManifestConflict } type ProjectGeneralPatch struct { - BuildVersion string `json:"buildVersion"` - DevCommand string `json:"devCommand"` + BuildVersion string `json:"buildVersion"` + DevCommand string `json:"devCommand"` + DevURL *string `json:"devURL,omitempty"` } type ProjectEnvironmentPatch struct { @@ -248,10 +250,21 @@ func (s *Service) applyProjectChanges( if change.General != nil { project.BuildVersion = workspacecore.NormalizeBuildVersion(change.General.BuildVersion) command := strings.TrimSpace(change.General.DevCommand) - if command == "" { + url := "" + if project.Dev != nil { + url = project.Dev.URL + } + if change.General.DevURL != nil { + var err error + url, err = localurl.Normalize(*change.General.DevURL) + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrInvalidInput, err) + } + } + if command == "" && url == "" { project.Dev = nil } else { - project.Dev = &workspacecore.ProjectDevOverride{Command: command} + project.Dev = &workspacecore.ProjectDevOverride{Command: command, URL: url} } applied++ } diff --git a/packages/cli/internal/application/manifest/service_test.go b/packages/cli/internal/application/manifest/service_test.go index d4d978f..9636918 100644 --- a/packages/cli/internal/application/manifest/service_test.go +++ b/packages/cli/internal/application/manifest/service_test.go @@ -261,3 +261,40 @@ func TestWorkspaceBindingAndProjectChangesPublishTogether(t *testing.T) { t.Fatalf("binding: %#v", config) } } + +func TestDevURLCanExistWithoutOverrideAndSurvivesLegacyPatches(t *testing.T) { + root, service, revision := seedManifest(t) + url := "http://localhost:3000/" + apply := func(patch *ProjectGeneralPatch) error { + result, err := service.ApplyManifestDraft(context.Background(), root, ApplyManifestInput{Revision: revision, Changes: []ProjectManifestPatch{{Project: "web", General: patch}}}) + if err == nil { + revision = result.Revision + } + return err + } + if err := apply(&ProjectGeneralPatch{DevURL: &url}); err != nil { + t.Fatal(err) + } + if err := apply(&ProjectGeneralPatch{DevCommand: "pnpm start"}); err != nil { + t.Fatal(err) + } + m, err := workspacecore.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + if m.Projects[0].Dev.URL != url || m.Projects[0].Dev.Command != "pnpm start" { + t.Fatal(m.Projects[0].Dev) + } + unsafe := "https://external.example/?token=x" + if err := apply(&ProjectGeneralPatch{DevURL: &unsafe}); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } + empty := "" + if err := apply(&ProjectGeneralPatch{DevURL: &empty}); err != nil { + t.Fatal(err) + } + m, _ = workspacecore.ReadManifest(root) + if m.Projects[0].Dev != nil { + t.Fatal("empty dev override not removed") + } +} diff --git a/packages/cli/internal/application/workspace/project_settings.go b/packages/cli/internal/application/workspace/project_settings.go index c96eb85..30538c3 100644 --- a/packages/cli/internal/application/workspace/project_settings.go +++ b/packages/cli/internal/application/workspace/project_settings.go @@ -35,6 +35,7 @@ type ProjectSettingsProject struct { PackageManager string `json:"packageManager,omitempty"` BuildVersion string `json:"buildVersion,omitempty"` DevCommand string `json:"devCommand,omitempty"` + DevURL string `json:"devURL,omitempty"` Build ProjectBuildSettings `json:"build"` Tasks *ProjectTasks `json:"tasks,omitempty"` DefaultEnvironment string `json:"defaultEnvironment,omitempty"` @@ -102,6 +103,10 @@ func (s *Service) projectSettings( sort.Strings(env.Keys) } + devURL := "" + if project.Dev != nil { + devURL = project.Dev.URL + } return ProjectSettings{ Schema: ProjectSettingsSchema, Root: root, @@ -116,6 +121,7 @@ func (s *Service) projectSettings( PackageManager: project.PackageManager, BuildVersion: project.BuildVersion, DevCommand: workspacecore.ProjectDev(manifest, project.Name), + DevURL: devURL, Build: projectBuildSettings(root, *project), Tasks: s.projectTasks(ctx, root, project.Name), DefaultEnvironment: defaultEnvironment, diff --git a/packages/cli/internal/core/workspace/manifest.go b/packages/cli/internal/core/workspace/manifest.go index dc018c5..8c4f026 100644 --- a/packages/cli/internal/core/workspace/manifest.go +++ b/packages/cli/internal/core/workspace/manifest.go @@ -99,6 +99,7 @@ type ProjectDevOverride struct { // Command is the full shell line executed by the platform shell // (sh on Unix, cmd.exe on Windows). Command string `json:"command,omitempty"` + URL string `json:"url,omitempty"` } // ProjectEnvOverride is the per-project env override. Carries no `kind` diff --git a/packages/cli/internal/modules/devservice/endpoints.go b/packages/cli/internal/modules/devservice/endpoints.go new file mode 100644 index 0000000..d97adaa --- /dev/null +++ b/packages/cli/internal/modules/devservice/endpoints.go @@ -0,0 +1,67 @@ +package devservice + +import ( + "context" + "net" + "net/url" + "regexp" + "strings" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/localurl" +) + +var ansi = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) +var addresses = regexp.MustCompile(`https?://[^\s<>"'\x1b]+`) + +func discoverURLs(text string) []string { + result := []string{} + text = ansi.ReplaceAllString(text, "") + for _, match := range addresses.FindAllStringIndex(text, -1) { + // A pipe write may end in the middle of a port or a credential query. + // Wait for a delimiter before publishing the address. + if match[1] == len(text) { + continue + } + candidate := text[match[0]:match[1]] + if normalized, err := localurl.Normalize(strings.TrimRight(candidate, ").,;")); err == nil { + result = append(result, normalized) + } + } + return result +} +func (m *Manager) probe(ctx context.Context, r *run) { + m.mu.Lock() + if r.state.Status != "running" { + m.mu.Unlock() + return + } + entries := append([]Endpoint{}, r.state.Endpoints...) + m.mu.Unlock() + for i, entry := range entries { + u, _ := url.Parse(entry.URL) + host := u.Hostname() + if host == "localhost" { + host = "127.0.0.1" + } + port := u.Port() + if port == "" { + port = "80" + if u.Scheme == "https" { + port = "443" + } + } + conn, err := (&net.Dialer{Timeout: 150 * time.Millisecond}).DialContext(ctx, "tcp", net.JoinHostPort(host, port)) + if err != nil && u.Hostname() == "localhost" { + conn, err = (&net.Dialer{Timeout: 150 * time.Millisecond}).DialContext(ctx, "tcp", net.JoinHostPort("::1", port)) + } + if err == nil { + _ = conn.Close() + } + m.mu.Lock() + if Active(r.state.Status) { + r.state.Endpoints[i].Reachable = err == nil + } + m.mu.Unlock() + } +} diff --git a/packages/cli/internal/modules/devservice/manager.go b/packages/cli/internal/modules/devservice/manager.go new file mode 100644 index 0000000..7a67535 --- /dev/null +++ b/packages/cli/internal/modules/devservice/manager.go @@ -0,0 +1,402 @@ +// Package devservice owns Dashboard dev processes for the lifetime of one serve. +// Workers use the normal task pipeline; only already-redacted output reaches here. +package devservice + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + "unicode/utf8" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/localurl" + process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" +) + +const maxLogBytes = 256 * 1024 +const maxRuns = 128 + +type Input struct{ Root, Project, Environment, URL string } +type Event struct { + Status string `json:"status"` +} +type Log struct { + Seq int64 `json:"seq"` + Text string `json:"text"` +} +type Endpoint struct { + URL string `json:"url"` + Reachable bool `json:"reachable"` +} +type Snapshot struct { + ID string `json:"id"` + Project string `json:"project"` + Environment string `json:"environment"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` + Endpoints []Endpoint `json:"endpoints"` + Logs []Log `json:"logs"` + FirstSeq int64 `json:"firstSeq"` + NextSeq int64 `json:"nextSeq"` +} + +func Active(status string) bool { + return status == "preparing" || status == "running" || status == "stopping" +} + +type run struct { + input Input + state Snapshot + cancel context.CancelFunc + done chan struct{} + bytes int + tail string + pendingUTF8 string + restart bool + restartInput Input +} +type Manager struct { + mu sync.Mutex + runs map[string]*run + gates map[string]chan struct{} + closed bool + sequence uint64 + command func(Input) (*exec.Cmd, error) + grace time.Duration +} + +func New() *Manager { + return &Manager{runs: map[string]*run{}, gates: map[string]chan struct{}{}, grace: 4 * time.Second, command: func(in Input) (*exec.Cmd, error) { + exe, err := os.Executable() + if err != nil { + return nil, err + } + cmd := process.Command(exe, "__service", "--project", in.Project, "--env", in.Environment, "-o", "json") + cmd.Dir = in.Root + // Bound waiting for inherited pipes even when a misbehaving child daemonizes. + cmd.WaitDelay = 2 * time.Second + return cmd, nil + }} +} +func key(root, project string) string { + if p, err := filepath.EvalSymlinks(root); err == nil { + root = p + } + return filepath.Clean(root) + "\x00" + project +} +func (m *Manager) Start(in Input) (Snapshot, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.start(in) +} +func (m *Manager) start(in Input) (Snapshot, error) { + if m.closed { + return Snapshot{}, i18n.Errorf("devservice.closed") + } + k := key(in.Root, in.Project) + if previous := m.runs[k]; previous != nil && Active(previous.state.Status) { + return snapshot(previous, "", 0), nil + } + if len(m.runs) >= maxRuns && m.runs[k] == nil { + var oldestKey string + var oldest *run + for candidate, r := range m.runs { + if !Active(r.state.Status) && (oldest == nil || r.state.StartedAt.Before(oldest.state.StartedAt)) { + oldestKey, oldest = candidate, r + } + } + if oldest == nil { + return Snapshot{}, i18n.Errorf("devservice.limit") + } + delete(m.runs, oldestKey) + } + ctx, cancel := context.WithCancel(context.Background()) + m.sequence++ + r := &run{input: in, cancel: cancel, done: make(chan struct{}), state: Snapshot{ID: fmt.Sprint(m.sequence), Project: in.Project, Environment: in.Environment, Status: "preparing", StartedAt: time.Now(), Endpoints: []Endpoint{}, NextSeq: 1}} + if url, err := localurl.Normalize(in.URL); err == nil && url != "" { + r.state.Endpoints = append(r.state.Endpoints, Endpoint{URL: url}) + } + m.runs[k] = r + rootKey := key(in.Root, "") + gate := m.gates[rootKey] + if gate == nil { + gate = make(chan struct{}, 1) + m.gates[rootKey] = gate + } + go m.execute(ctx, r, gate) + return snapshot(r, "", 0), nil +} +func snapshot(r *run, id string, after int64) Snapshot { + s := r.state + s.Endpoints = append([]Endpoint{}, s.Endpoints...) + s.Logs = []Log{} + s.FirstSeq = s.NextSeq + if len(r.state.Logs) > 0 { + s.FirstSeq = r.state.Logs[0].Seq + } + if id != s.ID { + after = 0 + } + for _, line := range r.state.Logs { + if line.Seq > after { + s.Logs = append(s.Logs, line) + } + } + return s +} +func (m *Manager) Get(root, project, id string, after int64) Snapshot { + m.mu.Lock() + defer m.mu.Unlock() + if r := m.runs[key(root, project)]; r != nil { + return snapshot(r, id, after) + } + return Snapshot{Project: project, Status: "stopped", Endpoints: []Endpoint{}, Logs: []Log{}, NextSeq: 1, FirstSeq: 1} +} +func (m *Manager) List(root string) []Snapshot { + m.mu.Lock() + defer m.mu.Unlock() + result := []Snapshot{} + for _, r := range m.runs { + if key(r.input.Root, "") == key(root, "") { + s := snapshot(r, r.state.ID, r.state.NextSeq) + result = append(result, s) + } + } + return result +} +func (m *Manager) Stop(root, project string) Snapshot { + m.mu.Lock() + defer m.mu.Unlock() + if r := m.runs[key(root, project)]; r != nil { + r.restart = false + if Active(r.state.Status) { + r.state.Status = "stopping" + r.cancel() + } + return snapshot(r, "", 0) + } + return Snapshot{Project: project, Status: "stopped", Endpoints: []Endpoint{}, Logs: []Log{}, NextSeq: 1, FirstSeq: 1} +} +func (m *Manager) Restart(in Input) (Snapshot, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return Snapshot{}, i18n.Errorf("devservice.closed") + } + if r := m.runs[key(in.Root, in.Project)]; r != nil && Active(r.state.Status) { + r.restart = true + r.restartInput = in + r.state.Status = "stopping" + r.cancel() + return snapshot(r, "", 0), nil + } + return m.start(in) +} +func (m *Manager) Close() { + m.mu.Lock() + m.closed = true + pending := []chan struct{}{} + for _, r := range m.runs { + if Active(r.state.Status) { + r.state.Status = "stopping" + r.cancel() + pending = append(pending, r.done) + } + } + m.mu.Unlock() + for _, done := range pending { + <-done + } +} +func (m *Manager) execute(ctx context.Context, r *run, gate chan struct{}) { + defer close(r.done) + defer r.cancel() + select { + case gate <- struct{}{}: + case <-ctx.Done(): + m.finish(r, 0) + return + } + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { <-gate }) } + defer release() + if ctx.Err() != nil { + m.finish(r, 0) + return + } + cmd, err := m.command(r.input) + if err != nil { + m.append(r, err.Error()+"\n") + m.finish(r, 1) + return + } + stdout, err := cmd.StdoutPipe() + if err != nil { + m.append(r, err.Error()+"\n") + m.finish(r, 1) + return + } + control, err := cmd.StdinPipe() + if err != nil { + _ = stdout.Close() + m.append(r, err.Error()+"\n") + m.finish(r, 1) + return + } + defer control.Close() + cmd.Stderr = logWriter{m, r} + if err = cmd.Start(); err != nil { + _ = stdout.Close() + m.append(r, err.Error()+"\n") + m.finish(r, 1) + return + } + eventsDone := make(chan struct{}) + go func() { + defer close(eventsDone) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 4096), 16*1024) + for scanner.Scan() { + var event Event + if json.Unmarshal(scanner.Bytes(), &event) == nil && event.Status == "running" { + m.mu.Lock() + if r.state.Status == "preparing" { + r.state.Status = "running" + } + m.mu.Unlock() + release() + } + } + }() + waited := make(chan error, 1) + go func() { waited <- cmd.Wait() }() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case err = <-waited: + _ = stdout.Close() + <-eventsDone + code := 0 + if err != nil { + code = process.ExitCode(err) + } + m.finish(r, code) + return + case <-ticker.C: + m.probe(ctx, r) + case <-ctx.Done(): + // Closing the private control pipe lets the worker cancel and run + // its secret-context cleanup on every platform, before forcing it. + _ = control.Close() + timer := time.NewTimer(m.grace) + select { + case err = <-waited: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: + _ = process.StopTree(cmd.Process) + err = <-waited + } + _ = stdout.Close() + <-eventsDone + code := 0 + if err != nil { + code = process.ExitCode(err) + } + m.finish(r, code) + return + } + } +} +func (m *Manager) finish(r *run, code int) { + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now() + r.state.FinishedAt = &now + r.state.ExitCode = &code + if r.state.Status == "stopping" { + r.state.Status = "stopped" + } else if code == 0 { + r.state.Status = "exited" + } else { + r.state.Status = "failed" + } + for i := range r.state.Endpoints { + r.state.Endpoints[i].Reachable = false + } + if r.restart && !m.closed { + _, _ = m.start(r.restartInput) + } +} + +type logWriter struct { + m *Manager + r *run +} + +func (w logWriter) Write(p []byte) (int, error) { w.m.append(w.r, string(p)); return len(p), nil } + +var _ io.Writer = logWriter{} + +func (m *Manager) append(r *run, text string) { + m.mu.Lock() + defer m.mu.Unlock() + text = r.pendingUTF8 + text + r.pendingUTF8 = "" + if len(text) > 0 { + last := len(text) - 1 + for last > 0 && !utf8.RuneStart(text[last]) { + last-- + } + if !utf8.FullRuneInString(text[last:]) { + r.pendingUTF8 = text[last:] + text = text[:last] + } + } + r.tail += text + if len(r.tail) > 8192 { + r.tail = r.tail[len(r.tail)-8192:] + } + for _, url := range discoverURLs(r.tail) { + found := false + for _, entry := range r.state.Endpoints { + if entry.URL == url { + found = true + } + } + if !found && len(r.state.Endpoints) < 8 { + r.state.Endpoints = append(r.state.Endpoints, Endpoint{URL: url}) + } + } + for len(text) > 0 { + n := min(len(text), 4096) + if n < len(text) { + for n > 0 && !utf8.RuneStart(text[n]) { + n-- + } + if n == 0 { + n = min(len(text), 4096) + } + } + r.state.Logs = append(r.state.Logs, Log{Seq: r.state.NextSeq, Text: text[:n]}) + r.state.NextSeq++ + r.bytes += n + text = text[n:] + } + for r.bytes > maxLogBytes || len(r.state.Logs) > 2048 { + r.bytes -= len(r.state.Logs[0].Text) + r.state.Logs[0] = Log{} + r.state.Logs = r.state.Logs[1:] + } +} diff --git a/packages/cli/internal/modules/devservice/manager_test.go b/packages/cli/internal/modules/devservice/manager_test.go new file mode 100644 index 0000000..8b147c6 --- /dev/null +++ b/packages/cli/internal/modules/devservice/manager_test.go @@ -0,0 +1,219 @@ +package devservice + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "strings" + "sync" + "testing" + "time" +) + +func TestServiceProcessHelper(t *testing.T) { + mode := os.Getenv("ONE_TEST_DEV_SERVICE") + if mode == "" { + return + } + if mode == "child" { + cmd := exec.Command(os.Args[0], "-test.run=^TestServiceProcessHelper$") + cmd.Env = append(os.Environ(), "ONE_TEST_DEV_SERVICE=stubborn") + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + os.Exit(2) + } + } else { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + os.Exit(3) + } + fmt.Fprintln(os.Stderr, "local http://"+listener.Addr().String()+"/") + defer listener.Close() + } + _ = json.NewEncoder(os.Stdout).Encode(Event{Status: "running"}) + if mode == "normal" { + _, _ = io.Copy(io.Discard, os.Stdin) + os.Exit(0) + } + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + for { + <-signals + if mode == "normal" { + os.Exit(0) + } + } +} +func testManager(t *testing.T, mode string) *Manager { + t.Helper() + m := New() + m.grace = 100 * time.Millisecond + m.command = func(in Input) (*exec.Cmd, error) { + cmd := exec.Command(os.Args[0], "-test.run=^TestServiceProcessHelper$") + cmd.Env = append(os.Environ(), "ONE_TEST_DEV_SERVICE="+mode) + cmd.WaitDelay = time.Second + return cmd, nil + } + t.Cleanup(m.Close) + return m +} +func awaitState(t *testing.T, m *Manager, in Input, check func(Snapshot) bool) Snapshot { + t.Helper() + deadline := time.Now().Add(6 * time.Second) + for time.Now().Before(deadline) { + s := m.Get(in.Root, in.Project, "", 0) + if check(s) { + return s + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("unexpected service state: %+v", m.Get(in.Root, in.Project, "", 0)) + return Snapshot{} +} +func TestLifecycleDeduplicatesRestartsAndReleasesPort(t *testing.T) { + for _, mode := range []string{"normal", "child"} { + t.Run(mode, func(t *testing.T) { + m := testManager(t, mode) + in := Input{Root: t.TempDir(), Project: "web", Environment: "dev"} + var wg sync.WaitGroup + ids := make(chan string, 16) + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s, err := m.Start(in) + if err != nil { + t.Error(err) + } + ids <- s.ID + }() + } + wg.Wait() + close(ids) + for id := range ids { + if id != "1" { + t.Errorf("duplicate run: %s", id) + } + } + first := awaitState(t, m, in, func(s Snapshot) bool { + return s.Status == "running" && len(s.Endpoints) > 0 && s.Endpoints[0].Reachable + }) + in.Environment = "prod" + if _, err := m.Restart(in); err != nil { + t.Fatal(err) + } + second := awaitState(t, m, in, func(s Snapshot) bool { + return s.ID != first.ID && s.Status == "running" && len(s.Endpoints) > 0 && s.Endpoints[0].Reachable + }) + if second.Environment != "prod" { + t.Fatal(second) + } + m.Stop(in.Root, in.Project) + awaitState(t, m, in, func(s Snapshot) bool { return s.Status == "stopped" }) + for _, endpoint := range append(first.Endpoints, second.Endpoints...) { + conn, err := net.DialTimeout("tcp", strings.TrimSuffix(strings.TrimPrefix(endpoint.URL, "http://"), "/"), 100*time.Millisecond) + if err == nil { + conn.Close() + t.Fatal("service child port survived shutdown") + } + } + }) + } +} +func TestStopAndCloseCancelQueuedPreparation(t *testing.T) { + m := testManager(t, "normal") + in := Input{Root: t.TempDir(), Project: "web"} + gate := make(chan struct{}, 1) + gate <- struct{}{} + m.gates[key(in.Root, "")] = gate + first, _ := m.Start(in) + m.Stop(in.Root, in.Project) + awaitState(t, m, in, func(s Snapshot) bool { return s.Status == "stopped" }) + second, _ := m.Start(in) + if first.ID == second.ID { + t.Fatal("run not replaced") + } + m.Close() + if _, err := m.Start(in); err == nil { + t.Fatal("started after close") + } +} +func TestLogsBoundedCursorAndWorkspaceIsolation(t *testing.T) { + m := New() + r := &run{input: Input{Root: t.TempDir(), Project: "web"}, state: Snapshot{ID: "1", NextSeq: 1}} + m.runs[key(r.input.Root, r.input.Project)] = r + for i := 0; i < 100; i++ { + m.append(r, strings.Repeat("hello", 1000)) + } + s := m.Get(r.input.Root, "web", "", 0) + size := 0 + for _, line := range s.Logs { + size += len(line.Text) + } + if size > maxLogBytes || s.FirstSeq <= 1 { + t.Fatal("unbounded logs") + } + m.append(r, "tail\n") + delta := m.Get(r.input.Root, "web", s.ID, s.NextSeq-1) + if len(delta.Logs) != 1 || delta.Logs[0].Text != "tail\n" { + t.Fatal(delta) + } + if got := m.Get(t.TempDir(), "web", "", 0); got.ID != "" { + t.Fatal("workspace isolation failed") + } + if got := m.Get(r.input.Root, "web", "old-run", 99999); len(got.Logs) == 0 { + t.Fatal("new run did not reset cursor") + } +} +func TestDiscoverOnlyLocalEndpoints(t *testing.T) { + urls := discoverURLs("\x1b[32mhttp://0.0.0.0:3000/\x1b[0m\nhttps://docs.example.com http://localhost:3000/?token=secret http://user:pw@127.0.0.1:3000/") + if len(urls) != 1 || urls[0] != "http://127.0.0.1:3000/" { + t.Fatal(urls) + } + m := New() + r := &run{state: Snapshot{Endpoints: []Endpoint{{URL: "http://127.0.0.1:1/"}}, Status: "running"}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + m.probe(ctx, r) + if r.state.Endpoints[0].Reachable { + t.Fatal("closed port is reachable") + } +} + +func TestLogsPreserveUTF8AcrossWrites(t *testing.T) { + m := New() + r := &run{state: Snapshot{NextSeq: 1}} + raw := []byte("中文开发服务\n") + for _, b := range raw { + m.append(r, string([]byte{b})) + } + var combined strings.Builder + for _, line := range r.state.Logs { + combined.WriteString(line.Text) + } + if combined.String() != string(raw) { + t.Fatal(combined.String()) + } +} + +func TestPartialURLIsNotPublished(t *testing.T) { + m := New() + r := &run{state: Snapshot{NextSeq: 1}} + m.append(r, "http://localhost:3") + if len(r.state.Endpoints) != 0 { + t.Fatal("partial port was published") + } + m.append(r, "000/?token=test\n") + if len(r.state.Endpoints) != 0 { + t.Fatal("credential URL was published") + } + m.append(r, "http://localhost:4000/\n") + if len(r.state.Endpoints) != 1 || r.state.Endpoints[0].URL != "http://localhost:4000/" { + t.Fatal(r.state.Endpoints) + } +} diff --git a/packages/cli/internal/modules/tasks/secrets_test.go b/packages/cli/internal/modules/tasks/secrets_test.go index f5e6020..41b2239 100644 --- a/packages/cli/internal/modules/tasks/secrets_test.go +++ b/packages/cli/internal/modules/tasks/secrets_test.go @@ -92,3 +92,33 @@ func TestSchedulerMasksCachedOutputAcrossProjects(t *testing.T) { t.Fatalf("unsafe cached logs: %q", combined) } } + +// The Dashboard worker merges both streams into one private console pipe. +func TestDevConsoleMasksBeforeForwardingAndReportsSchedulerStart(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + w := taskWorkspace(t) + w.Manifest().Env = &workspace.EnvironmentConfig{ProjectID: "remote"} + for i := range w.Manifest().Projects { + w.Manifest().Projects[i].Dev = &workspace.ProjectDevOverride{Command: "echo dev"} + } + if err := workspace.WriteManifest(w.Root(), w.Manifest()); err != nil { + t.Fatal(err) + } + opts := Options{Name: "dev", Projects: []string{"web", "lib"}, Cache: "off", UI: "stream", Jobs: 1} + plan, err := NewPlan(w, opts) + if err != nil { + t.Fatal(err) + } + started := false + service := Service{Provider: replayProvider{}, Loaders: secrets.MustRegistry(&countingLoader{calls: map[string]int{}, value: "private-test-value-"}), Prepare: func(context.Context, dependencies.Input) error { return nil }, OnStarted: func() { started = true }} + var console bytes.Buffer + result, err := service.Execute(context.Background(), w, plan, opts, nil, &console, &console) + if err != nil || result.Status != "succeeded" || !started { + t.Fatalf("%+v %v %v", result, err, started) + } + if strings.Contains(console.String(), "private-test-value-") || strings.Count(console.String(), "[REDACTED]") != 2 { + t.Fatal("unsafe console output") + } +} diff --git a/packages/cli/internal/modules/tasks/service.go b/packages/cli/internal/modules/tasks/service.go index a19bc4e..43227ba 100644 --- a/packages/cli/internal/modules/tasks/service.go +++ b/packages/cli/internal/modules/tasks/service.go @@ -21,9 +21,11 @@ import ( ) type Service struct { - Provider runtimeport.Provider - Loaders *secrets.Registry - Prepare func(context.Context, dependencies.Input) error + // OnStarted runs after the scheduler starts, before waiting for its exit. + OnStarted func() + Provider runtimeport.Provider + Loaders *secrets.Registry + Prepare func(context.Context, dependencies.Input) error } type Result struct { Schema string `json:"schema"` @@ -128,7 +130,7 @@ func (s Service) Execute(ctx context.Context, w execution.Workspace, p *Plan, op }) command, err := s.Provider.PrepareCLI(ctx, runtimeport.Command{Directory: w.Root(), Argv: argv, Env: env}) if err != nil { - return nil, err + return nil, redact.New(variables).Error(err) } child := process.CommandContext(ctx, command.Argv[0], command.Argv[1:]...) child.Cancel = func() error { return process.StopTree(child.Process) } @@ -145,7 +147,13 @@ func (s Service) Execute(ctx context.Context, w execution.Workspace, p *Plan, op stdout, flushOut := filter.Writer(child.Stdout) stderr, flushErr := filter.Writer(child.Stderr) child.Stdout, child.Stderr = stdout, stderr - err = child.Run() + err = child.Start() + if err == nil { + if s.OnStarted != nil { + s.OnStarted() + } + err = child.Wait() + } err = errors.Join(err, flushOut(), flushErr()) result := &Result{Schema: "one-cli/task-result/v1", Status: "succeeded", Entries: p.Entries, Tasks: p.Tasks} if err != nil { diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index bc2fa26..ae9dd45 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -153,9 +153,9 @@ "exec.command_required": "No command was provided after --. Use `one exec [project] -- [args...]`.", "exec.project_args_invalid": "Only one project name or path may appear before --.", "exec.selector_conflict": "The positional project and --project refer to different projects; keep only one form.", - "serve.short": "Open the local settings page", + "serve.short": "Open the local Dashboard", "serve.started": "✓ Local settings page started: %s", - "serve.instructions": " Manage local connections in the browser; press Ctrl-C to stop.", + "serve.instructions": " Manage projects, development services, and connections in the browser; press Ctrl-C to stop all services.", "serve.browser_failed": " Could not open the browser automatically (%v); copy the URL above.", "serve.registry_warning": "The workspace list was not updated (%v); Dashboard will still start, but this workspace may be absent", "serve.flag.host": "Loopback host for the local settings page", @@ -317,7 +317,7 @@ "exec.control_variable": "The shared folder contains process-control variable %s. Use --keys to select application credentials before injection.", "locale.tip": "Shows the saved language preference and the language active in this terminal. Pass auto, zh-CN, or en-US to change the preference.\n\nauto follows LC_ALL, LC_MESSAGES, then LANG; the fallback is English. The Dashboard shares this preference. Project and third-party tool logs keep their original language.", "exec.tip": "Runs a command in the selected project directory. Select a project by name or relative path, or run from inside a project. The positional project and --project must agree when both are used.\n\nUse -- to separate One options from the child command. When the workspace has an env binding, variables are fetched from Infisical and injected into the child process. --env selects the environment. Injected values override same-name shell variables. Without a binding, or for a disabled project, the shell environment is used directly. Local .env files are not loaded by One.\n\nWith --global, provide --env and --path explicitly. The command keeps the current working directory. Use --keys to select shared credentials.\n\nKnown injected secret values are masked on stdout and stderr.", - "serve.tip": "Starts a local settings page for workspaces, project configuration, Infisical login, and shared credentials. Review manifest changes before saving. Variable lists show names and descriptions; plaintext is fetched only when viewed or copied.\n\nBy default, binds to 127.0.0.1 on an available port and opens your browser. Press Ctrl-C to stop. Host and Origin checks protect the local interface.", + "serve.tip": "Starts a local Dashboard for workspaces, development services, project configuration, Infisical login, and shared credentials. The project Run tab provides start, stop, restart, a live console, and local access links. Review manifest changes before saving. Plaintext variables are fetched only when viewed or copied.\n\nBy default, binds to 127.0.0.1 on an available port and opens your browser. Closing the page keeps services running; Ctrl-C stops the Dashboard and its services. Host and Origin checks protect the interface.", "template.compat.project": "Workspace setting %s/%s is incompatible with project %q (template %s); this template supports %s backends: [%s].", "template.compat.workspace": "Workspace setting %s/%s is incompatible with template %s; this template supports %s backends: [%s]. Switch the manifest to %s, or keep %s for other projects.", "templates.empty": "No templates registered.", @@ -670,5 +670,11 @@ "error.RUN_DOTENV_MISSING.summary": "Reserved error code from the retired local environment workflow.", "miseconfig.trust_failed": "Could not trust generated mise configuration %s: %w. Review it and run one mise trust %q.", "creation.mise_trust_warning": "Files were created, but automatic mise trust did not complete: %s", - "env.set_syntax_conflict": "Use either KEY=VALUE or KEY VALUE, without combining the two forms." + "env.set_syntax_conflict": "Use either KEY=VALUE or KEY VALUE, without combining the two forms.", + "devservice.closed": "The service manager is shutting down.", + "devservice.limit": "Too many services are running. Stop a service first.", + "devservice.url_invalid": "Use a local HTTP(S) URL without credentials, query parameters, or fragments.", + "devservice.interactive": "This dev task needs an interactive terminal. Run one dev in an external console.", + "devservice.unavailable": "The service manager is unavailable. Restart one serve.", + "devservice.forget_active": "Stop this workspace’s development services before removing it from the list." } diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 769337b..21e5214 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -153,9 +153,9 @@ "exec.command_required": "-- 后没有要执行的命令。请使用 `one exec [project] -- [args...]`。", "exec.project_args_invalid": "-- 前最多只能提供一个项目名或路径。", "exec.selector_conflict": "位置参数和 --project 指向了不同项目;请只保留一种写法。", - "serve.short": "打开本地设置页面", + "serve.short": "打开本地 Dashboard", "serve.started": "✓ 本地设置页面已启动:%s", - "serve.instructions": " 在浏览器中管理本机连接;按 Ctrl-C 停止。", + "serve.instructions": " 在浏览器中管理项目、开发服务和连接;按 Ctrl-C 停止全部服务。", "serve.browser_failed": " 无法自动打开浏览器(%v);请复制上方 URL。", "serve.registry_warning": "工作区列表未更新(%v);Dashboard 仍会启动,但本次工作区可能不会出现在列表中", "serve.flag.host": "本地设置页面使用的回环地址", @@ -317,7 +317,7 @@ "exec.control_variable": "全局目录包含进程控制变量 %s,拒绝注入;请通过 --keys 选择业务凭据", "locale.tip": "查看保存的语言偏好和当前终端中生效的语言。传入 auto、zh-CN 或 en-US 可修改偏好。\n\nauto 依次读取 LC_ALL、LC_MESSAGES、LANG,未识别时使用英文。Dashboard 共用这份偏好。项目和第三方工具日志保留原始语言。", "exec.tip": "在选定的项目目录执行命令。可按名称或相对路径选择项目,也可从项目目录内执行;同时提供位置参数和 --project 时,两者必须一致。\n\n使用 -- 分隔 One 参数和子进程命令。工作区配置 env 绑定后,从 Infisical 获取变量并注入子进程,--env 指定环境;注入值覆盖同名 shell 变量。未配置绑定或项目禁用 env 时,直接使用 shell 环境。One 不读取本地 .env 文件。\n\n使用 --global 时必须明确提供 --env 和 --path,命令保持当前工作目录。可用 --keys 选择共享凭据。\n\n标准输出和标准错误中的已知注入密钥值会被脱敏。", - "serve.tip": "启动本地设置页面,管理工作区、项目配置、Infisical 登录和共享凭据。Manifest 修改须审阅后保存。变量列表只显示名称和说明,查看或复制时才读取明文。\n\n默认绑定 127.0.0.1 的可用端口并打开浏览器,按 Ctrl-C 退出。接口会校验 Host 和 Origin。", + "serve.tip": "启动本地 Dashboard,管理工作区、开发服务、项目配置、Infisical 登录和共享凭据。项目运行页支持启动、停止、重启、实时控制台及本机访问链接。Manifest 修改先审阅再保存;变量明文仅在查看或复制时读取。\n\n默认绑定 127.0.0.1 的可用端口并打开浏览器。关闭页面不影响开发服务;按 Ctrl-C 关闭 Dashboard 及其启动的服务。接口受到 Host 和 Origin 校验保护。", "template.compat.project": "工作区当前 %s/%s 与 subproject %q(模板 %s)不兼容;该模板的 %s 域支持: [%s]", "template.compat.workspace": "工作区当前 %s/%s 不适用于模板 %s;该模板的 %s 域支持: [%s]。建议手动调整 manifest 切到 %s,或保持现状(仅其他 subproject 走 %s)", "templates.empty": "没有已注册的模板。", @@ -670,5 +670,11 @@ "error.RUN_DOTENV_MISSING.summary": "旧本地环境变量流程保留的错误码。", "miseconfig.trust_failed": "无法信任生成的 mise 配置 %s:%w。检查后可执行 one mise trust %q。", "creation.mise_trust_warning": "文件已生成,但自动信任 mise 配置未完成:%s", - "env.set_syntax_conflict": "请使用 KEY=VALUE 或 KEY VALUE,不要混用两种写法。" + "env.set_syntax_conflict": "请使用 KEY=VALUE 或 KEY VALUE,不要混用两种写法。", + "devservice.closed": "服务管理器正在关闭。", + "devservice.limit": "运行中的服务过多,请先停止一些服务。", + "devservice.url_invalid": "访问地址需要是本机 HTTP(S) 地址,且不含凭据、查询参数或片段。", + "devservice.interactive": "此 dev 任务需要交互式终端,请在外部控制台运行 one dev。", + "devservice.unavailable": "服务管理器不可用,请重新启动 one serve。", + "devservice.forget_active": "请先停止此工作区的开发服务,再从列表移除工作区。" } diff --git a/packages/cli/internal/platform/localurl/localurl.go b/packages/cli/internal/platform/localurl/localurl.go new file mode 100644 index 0000000..a9c4837 --- /dev/null +++ b/packages/cli/internal/platform/localurl/localurl.go @@ -0,0 +1,44 @@ +package localurl + +import ( + "net" + "net/url" + "strconv" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +// Normalize only accepts browser-safe loopback addresses. No credentials or +// query strings are retained, and arbitrary DNS names cannot trigger probes. +func Normalize(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return "", i18n.Errorf("devservice.url_invalid") + } + host := u.Hostname() + ip := net.ParseIP(host) + if host == "0.0.0.0" { + host = "127.0.0.1" + } else if host == "::" { + host = "::1" + } else if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return "", i18n.Errorf("devservice.url_invalid") + } + if port := u.Port(); port != "" { + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return "", i18n.Errorf("devservice.url_invalid") + } + u.Host = net.JoinHostPort(host, port) + } else if strings.Contains(host, ":") { + u.Host = "[" + host + "]" + } else { + u.Host = host + } + return u.String(), nil +} diff --git a/packages/cli/internal/transport/cobra/run/cmd.go b/packages/cli/internal/transport/cobra/run/cmd.go index 68d51ab..4a271e9 100644 --- a/packages/cli/internal/transport/cobra/run/cmd.go +++ b/packages/cli/internal/transport/cobra/run/cmd.go @@ -19,7 +19,7 @@ import ( func Commands(loaders *secrets.Registry, provider runtimeport.Provider) []*cobra.Command { service := tasks.Service{Provider: provider, Loaders: loaders} - return []*cobra.Command{command("run", service), command("build", service), command("dev", service), internal(false), internal(true)} + return []*cobra.Command{command("run", service), command("build", service), command("dev", service), internal(false), internal(true), serviceWorker(service)} } func command(kind string, service tasks.Service) *cobra.Command { opts := tasks.Options{Jobs: 1, Cache: "local-only", UI: "auto"} diff --git a/packages/cli/internal/transport/cobra/run/service.go b/packages/cli/internal/transport/cobra/run/service.go new file mode 100644 index 0000000..dcdb491 --- /dev/null +++ b/packages/cli/internal/transport/cobra/run/service.go @@ -0,0 +1,61 @@ +package runcmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/spf13/cobra" + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/tasks" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" +) + +// The private worker isolates Cobra's process-wide state. stdout is a bounded +// lifecycle protocol; stderr contains the task pipeline's redacted console. +func serviceWorker(service tasks.Service) *cobra.Command { + var project, environment string + cmd := &cobra.Command{Use: "__service", Hidden: true, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + ctx, stop := process.SignalContext(cmd.Context()) + defer stop() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + // This private pipe is owned by the Dashboard, never forwarded to the + // dev task. EOF cancels preparation/execution even on Windows or if + // the Dashboard disappears without sending a signal. + go func() { _, _ = io.Copy(io.Discard, cmd.InOrStdin()); cancel() }() + work := func() error { + w, err := execution.ResolveWorkspace(ctx) + if err != nil { + return err + } + opts := tasks.Options{Name: "dev", Projects: []string{project}, Environment: environment, Jobs: 1, UI: "stream", Cache: "off"} + if project == "" { + return i18n.Errorf("tasks.context_invalid") + } + plan, err := service.Plan(ctx, w, opts) + if err != nil { + return err + } + for _, task := range plan.Tasks { + if task.Raw || task.Interactive { + return i18n.Errorf("devservice.interactive") + } + } + service.OnStarted = func() { _ = json.NewEncoder(cmd.OutOrStdout()).Encode(devservice.Event{Status: "running"}) } + _, err = service.Execute(ctx, w, plan, opts, nil, cmd.ErrOrStderr(), cmd.ErrOrStderr()) + return err + } + if err := work(); err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), err) + return &process.ExitStatus{Code: process.ExitCode(err)} + } + return nil + }} + cmd.Flags().StringVar(&project, "project", "", "") + cmd.Flags().StringVar(&environment, "env", "", "") + return cmd +} diff --git a/packages/cli/internal/transport/http/handlers_services.go b/packages/cli/internal/transport/http/handlers_services.go new file mode 100644 index 0000000..d6ec75b --- /dev/null +++ b/packages/cli/internal/transport/http/handlers_services.go @@ -0,0 +1,129 @@ +package serve + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/localurl" +) + +func registerServiceRoutes(mux *http.ServeMux, opts MuxOpts) { + for _, route := range []struct { + pattern string + action string + }{ + {"GET /services", "list"}, + {"GET /projects/{name}/service", "get"}, + {"GET /projects/{name}/service/events", "events"}, + {"POST /projects/{name}/service/start", "start"}, + {"POST /projects/{name}/service/stop", "stop"}, + {"POST /projects/{name}/service/restart", "restart"}, + } { + factory := func(scoped MuxOpts) http.HandlerFunc { return handleService(scoped, route.action) } + method, path, _ := strings.Cut(route.pattern, " ") + mux.HandleFunc(method+" /workspace"+path, factory(opts)) + wrapper := handleResolvedWorkspace + if method == "GET" || route.action == "stop" { + wrapper = handleResolvedWorkspaceRead + } + mux.HandleFunc(method+" /workspaces/{entryId}"+path, wrapper(opts, factory)) + } +} +func handleService(opts MuxOpts, action string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if opts.WorkspaceRoot == "" { + writeNoWorkspace(w) + return + } + m := opts.ServiceManager + if m == nil { + writeError(w, http.StatusServiceUnavailable, cliErrors.ONE_CLI_ERROR, i18n.T("devservice.unavailable"), nil) + return + } + w.Header().Set("Cache-Control", "no-store") + project := r.PathValue("name") + switch action { + case "list": + writeJSON(w, http.StatusOK, map[string]any{"services": m.List(opts.WorkspaceRoot)}) + return + case "get": + writeJSON(w, http.StatusOK, m.Get(opts.WorkspaceRoot, project, "", 0)) + return + case "stop": + writeJSON(w, http.StatusAccepted, m.Stop(opts.WorkspaceRoot, project)) + return + case "events": + streamService(w, r, m, opts.WorkspaceRoot, project) + return + } + var body struct { + Environment string `json:"environment"` + } + if err := decodeJSON(r, &body); err != nil { + writeBadPayload(w, err.Error()) + return + } + settings, err := opts.WorkspaceService.ProjectSettings(r.Context(), opts.WorkspaceRoot, project, body.Environment) + if err != nil { + writeWorkspaceMutationErr(w, err) + return + } + url, err := localurl.Normalize(settings.Project.DevURL) + if err != nil { + writeBadPayload(w, err.Error()) + return + } + environment := settings.Environment + if environment == "" { + environment = settings.Project.DefaultEnvironment + } + input := devservice.Input{Root: opts.WorkspaceRoot, Project: settings.Project.Name, Environment: environment, URL: url} + var state devservice.Snapshot + if action == "restart" { + state, err = m.Restart(input) + } else { + state, err = m.Start(input) + } + if err != nil { + writeError(w, http.StatusConflict, cliErrors.ONE_CLI_ERROR, err.Error(), nil) + return + } + writeJSON(w, http.StatusAccepted, state) + } +} +func streamService(w http.ResponseWriter, r *http.Request, m *devservice.Manager, root, project string) { + flusher, ok := w.(http.Flusher) + if !ok { + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Accel-Buffering", "no") + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + id := r.URL.Query().Get("run") + after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + // Reconnects use the cursor in the URL; repeating a batch is safe for clients. + for { + state := m.Get(root, project, id, after) + payload, _ := json.Marshal(state) + _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(5 * time.Second)) + if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil { + return + } + flusher.Flush() + id = state.ID + after = state.NextSeq - 1 + select { + case <-r.Context().Done(): + return + case <-ticker.C: + } + } +} diff --git a/packages/cli/internal/transport/http/handlers_services_test.go b/packages/cli/internal/transport/http/handlers_services_test.go new file mode 100644 index 0000000..d6d2428 --- /dev/null +++ b/packages/cli/internal/transport/http/handlers_services_test.go @@ -0,0 +1,76 @@ +package serve + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" +) + +func TestServiceRoutesResolveScopeAndRejectInvalidLaunches(t *testing.T) { + withIsolatedConfig(t) + root := seedRegistryWorkspace(t, "dev-id", "Development", "web") + registry := newRegistryService(t) + entry := observeRegistryWorkspace(t, registry, root) + manager := devservice.New() + manager.Close() // validate payloads without starting a process + mux := BuildMux(MuxOpts{UIDisabled: true, ExpectedHosts: map[string]struct{}{registryTestHost: {}}, SelfOrigin: "http://" + registryTestHost, WorkspaceRoot: root, RegistryService: registry, ServiceManager: manager}) + for _, base := range []string{"/api/workspace", "/api/workspaces/" + entry.EntryID} { + for _, tc := range []struct { + path, body string + code int + }{ + {"/projects/web/service/start", `{"environment":"dev","command":"touch surprise"}`, 400}, + {"/projects/missing/service/start", `{"environment":"dev"}`, 404}, + {"/projects/web/service/start", `{"environment":"../unsafe"}`, 400}, + {"/projects/web/service/start", `{"environment":"dev"}`, 409}, + {"/projects/web/service/restart", `{"environment":"dev"}`, 409}, + {"/projects/web/service/stop", `{}`, 202}, + } { + rec := registryRequest(t, mux, http.MethodPost, base+tc.path, strings.NewReader(tc.body)) + if rec.Code != tc.code { + t.Fatalf("%s: %d %s", tc.path, rec.Code, rec.Body.String()) + } + } + rec := registryRequest(t, mux, http.MethodGet, base+"/services", nil) + if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal(rec) + } + } + req := httptest.NewRequest("POST", "/api/workspace/projects/web/service/start", strings.NewReader(`{}`)) + req.Host = registryTestHost + req.Header.Set("Origin", "https://evil.example") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != 403 { + t.Fatal(rec.Code) + } + rec = registryRequest(t, mux, http.MethodPost, "/api/workspaces/unknown/projects/web/service/start", strings.NewReader(`{}`)) + if rec.Code != 404 { + t.Fatal(rec.Code) + } +} +func TestServiceEventsFlushImmediatelyAndCancel(t *testing.T) { + manager := devservice.New() + defer manager.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { streamService(w, r, manager, "/test", "web") })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", server.URL, nil) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + line, err := bufio.NewReader(res.Body).ReadString('\n') + if err != nil || !strings.Contains(line, `"status":"stopped"`) { + t.Fatal(line, err) + } + cancel() +} diff --git a/packages/cli/internal/transport/http/handlers_workspaces.go b/packages/cli/internal/transport/http/handlers_workspaces.go index 2c3e98b..749af1b 100644 --- a/packages/cli/internal/transport/http/handlers_workspaces.go +++ b/packages/cli/internal/transport/http/handlers_workspaces.go @@ -11,7 +11,9 @@ import ( "net/http" workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) func registerWorkspacesRoutes(mux *http.ServeMux, opts MuxOpts) { @@ -81,6 +83,16 @@ func handleForgetWorkspace(opts MuxOpts) http.HandlerFunc { writeWorkspaceRegistryErr(w, entryID, workspaceapp.ErrRegistryEntryNotFound) return } + if opts.ServiceManager != nil { + if resolved, err := opts.RegistryService.ResolveRead(r.Context(), entryID); err == nil { + for _, service := range opts.ServiceManager.List(resolved.Root) { + if devservice.Active(service.Status) { + writeError(w, http.StatusConflict, cliErrors.ONE_CLI_ERROR, i18n.T("devservice.forget_active"), nil) + return + } + } + } + } if err := opts.RegistryService.Forget(r.Context(), entryID); err != nil { writeWorkspaceRegistryErr(w, entryID, err) return diff --git a/packages/cli/internal/transport/http/middleware.go b/packages/cli/internal/transport/http/middleware.go index d1959ce..7b5e0c0 100644 --- a/packages/cli/internal/transport/http/middleware.go +++ b/packages/cli/internal/transport/http/middleware.go @@ -23,6 +23,7 @@ import ( workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" @@ -32,9 +33,10 @@ import ( // MuxOpts is the static configuration for one running server. Tests // construct it directly; production goes through Run. type MuxOpts struct { - UIDisabled bool - ExpectedHosts map[string]struct{} - SelfOrigin string + ServiceManager *devservice.Manager + UIDisabled bool + ExpectedHosts map[string]struct{} + SelfOrigin string // WorkspaceRoot is the absolute path to the workspace `one serve` was // launched in, or "" when launched outside a workspace. Handlers read // it through opts capture; we don't auto-detect per request because @@ -106,6 +108,7 @@ func BuildMux(opts MuxOpts) http.Handler { registerWorkspaceMutateRoutes(api, opts) registerSecretRoutes(api, opts) registerWorkspacesRoutes(api, opts) + registerServiceRoutes(api, opts) root := http.NewServeMux() root.Handle("/api/", http.StripPrefix("/api", api)) diff --git a/packages/cli/internal/transport/http/server.go b/packages/cli/internal/transport/http/server.go index 1a768e8..c75659f 100644 --- a/packages/cli/internal/transport/http/server.go +++ b/packages/cli/internal/transport/http/server.go @@ -19,6 +19,7 @@ import ( workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" @@ -116,12 +117,15 @@ func Run(ctx context.Context, opts Opts, ready func(Result)) error { Port: port, } + manager := devservice.New() + defer manager.Close() mux := BuildMux(MuxOpts{ - UIDisabled: opts.UIDisabled, - ExpectedHosts: expectedHosts(opts.Host, port), - SelfOrigin: selfOrigin, - WorkspaceRoot: opts.WorkspaceRoot, - Catalog: opts.Catalog, + ServiceManager: manager, + UIDisabled: opts.UIDisabled, + ExpectedHosts: expectedHosts(opts.Host, port), + SelfOrigin: selfOrigin, + WorkspaceRoot: opts.WorkspaceRoot, + Catalog: opts.Catalog, ManifestService: opts.ManifestService, CreationService: opts.CreationService, @@ -131,9 +135,11 @@ func Run(ctx context.Context, opts Opts, ready func(Result)) error { }) server := &http.Server{ Handler: mux, + BaseContext: func(net.Listener) context.Context { return ctx }, ReadHeaderTimeout: 5 * time.Second, } + defer server.Close() if ready != nil { ready(res) } diff --git a/packages/cli/testdata/reference/help/serve.txt b/packages/cli/testdata/reference/help/serve.txt index 7dac1ee..2a45348 100644 --- a/packages/cli/testdata/reference/help/serve.txt +++ b/packages/cli/testdata/reference/help/serve.txt @@ -1,14 +1,14 @@ DESCRIPTION -Open the local settings page +Open the local Dashboard USAGE one serve [flags] TIPS -Starts a local settings page for workspaces, project configuration, Infisical login, and shared credentials. Review manifest changes before saving. Variable lists show names and descriptions; plaintext is fetched only when viewed or copied. +Starts a local Dashboard for workspaces, development services, project configuration, Infisical login, and shared credentials. The project Run tab provides start, stop, restart, a live console, and local access links. Review manifest changes before saving. Plaintext variables are fetched only when viewed or copied. -By default, binds to 127.0.0.1 on an available port and opens your browser. Press Ctrl-C to stop. Host and Origin checks protect the local interface. +By default, binds to 127.0.0.1 on an available port and opens your browser. Closing the page keeps services running; Ctrl-C stops the Dashboard and its services. Host and Origin checks protect the interface. COMMON OPTIONS --host Loopback host for the local settings page diff --git a/packages/cli/tests/e2e/services_unix_test.go b/packages/cli/tests/e2e/services_unix_test.go new file mode 100644 index 0000000..02806a1 --- /dev/null +++ b/packages/cli/tests/e2e/services_unix_test.go @@ -0,0 +1,133 @@ +//go:build unix + +package cli_test + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" + process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" +) + +func TestDevHTTPHelper(t *testing.T) { + if os.Getenv("ONE_TEST_DEV_HTTP") != "1" { + return + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + os.Exit(1) + } + fmt.Fprintln(os.Stdout, "\x1b[32mReady http://"+listener.Addr().String()+"/\x1b[0m") + _ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "service-ready") })) + os.Exit(0) +} +func TestE2E_DashboardServiceLifecycle(t *testing.T) { + root := devTerminalFixture(t, true) + contextDir := t.TempDir() + t.Setenv("TMPDIR", contextDir) + script := "exec env ONE_TEST_DEV_HTTP=1 '" + strings.ReplaceAll(os.Args[0], "'", "'\\''") + "' -test.run=^TestDevHTTPHelper$\n" + buildWrite(t, root, "packages/lib/dev.sh", script) + buildWrite(t, root, "apps/web/dev.sh", script) + cmd := exec.Command(binaryPath(t), "serve", "--port", "0", "--open=false", "-o", "json") + cmd.Dir = root + cmd.Env = prependPath(os.Environ(), strings.TrimSuffix(binaryPath(t), "/one")) + var out, errOut safeBuf + cmd.Stdout = &out + cmd.Stderr = &errOut + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + t.Cleanup(func() { _ = process.StopTree(cmd.Process) }) + envelope := waitForEnvelope(t, &out, 5*time.Second, errOut.String) + base := envelope["url"].(string) + "api/workspace" + client := &http.Client{Timeout: 2 * time.Second} + request := func(method, project, action string) devservice.Snapshot { + t.Helper() + path := base + "/projects/" + project + "/service" + if action != "" { + path += "/" + action + } + req, _ := http.NewRequest(method, path, strings.NewReader(`{"environment":"dev"}`)) + response, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + if response.StatusCode >= 300 { + t.Fatalf("%d: %s", response.StatusCode, raw) + } + var state devservice.Snapshot + if err = json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + return state + } + wait := func(project string, predicate func(devservice.Snapshot) bool) devservice.Snapshot { + t.Helper() + var state devservice.Snapshot + for deadline := time.Now().Add(12 * time.Second); time.Now().Before(deadline); { + state = request("GET", project, "") + if predicate(state) { + return state + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("service never became ready: %+v\n%s", state, errOut.String()) + return state + } + ready := func(s devservice.Snapshot) bool { + return s.Status == "running" && len(s.Endpoints) > 0 && s.Endpoints[0].Reachable + } + first := request("POST", "lib", "start") + if again := request("POST", "lib", "start"); again.ID != first.ID { + t.Fatal("duplicate process") + } + first = wait("lib", ready) + // A second project can prepare and run while the first is still alive. + request("POST", "web", "start") + web := wait("web", ready) + res, err := client.Get(first.Endpoints[0].URL) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + request("POST", "lib", "restart") + second := wait("lib", func(s devservice.Snapshot) bool { return s.ID != first.ID && ready(s) }) + request("POST", "lib", "stop") + wait("lib", func(s devservice.Snapshot) bool { return s.Status == "stopped" }) + if err = cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(9 * time.Second): + t.Fatal("Dashboard shutdown did not finish") + } + remaining, err := filepath.Glob(filepath.Join(contextDir, "one-task-context-*")) + if err != nil || len(remaining) != 0 { + t.Fatalf("task context survived shutdown: %v %v", remaining, err) + } + for _, s := range []devservice.Snapshot{first, second, web} { + conn, err := net.DialTimeout("tcp", strings.TrimSuffix(strings.TrimPrefix(s.Endpoints[0].URL, "http://"), "/"), 100*time.Millisecond) + if err == nil { + conn.Close() + t.Fatal("orphaned service port") + } + } +} From 605ea56790e927c69760518bfb8ddb4b43270c77 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Mon, 28 Sep 2026 22:30:13 +0800 Subject: [PATCH 08/16] fix(cli): treat dev and build as ordinary task shorthand --- README.md | 4 +- apps/docs/content/docs/en/build.md | 4 +- apps/docs/content/docs/en/cli-overview.md | 4 +- apps/docs/content/docs/en/dev.md | 4 +- apps/docs/content/docs/en/run.md | 2 +- apps/docs/content/docs/zh/build.md | 4 +- apps/docs/content/docs/zh/cli-overview.md | 4 +- apps/docs/content/docs/zh/dev.md | 4 +- apps/docs/content/docs/zh/run.md | 2 +- .../cli/internal/bootstrap/cli/root_test.go | 11 +--- .../bootstrap/cli/task_shorthand_test.go | 3 + .../internal/platform/i18n/locales/en-US.json | 11 +--- .../internal/platform/i18n/locales/zh-CN.json | 11 +--- .../cli/internal/transport/cobra/run/cmd.go | 34 +++-------- .../cli/testdata/reference/help/build.txt | 25 -------- packages/cli/testdata/reference/help/dev.txt | 25 -------- packages/cli/testdata/reference/help/root.txt | 2 - packages/cli/testdata/reference/help/run.txt | 2 +- .../cli/tests/e2e/snapshot_e2e_ux_test.go | 61 +++++++++++-------- packages/cli/tests/e2e/task_shortcuts_test.go | 42 +++++++++---- .../cli/tools/verify-cli-references/main.go | 41 ++++++++----- .../tools/verify-cli-references/main_test.go | 33 ++++++++++ packages/cli/tools/verify-help/main.go | 23 ++++--- packages/cli/tools/verify-help/main_test.go | 12 +++- 24 files changed, 191 insertions(+), 177 deletions(-) delete mode 100644 packages/cli/testdata/reference/help/build.txt delete mode 100644 packages/cli/testdata/reference/help/dev.txt create mode 100644 packages/cli/tools/verify-cli-references/main_test.go diff --git a/README.md b/README.md index d8a3dd6..93d2913 100644 --- a/README.md +++ b/README.md @@ -89,14 +89,14 @@ one add nestjs-api --name api |---|---| | `one create ` | Create an empty workspace | | `one add ` | Add another app, service, docs site, or library | -| `one dev [-p project]` | Run development tasks through mise; repeat -p to select services | -| `one build [-p project]` | Build all or selected projects in dependency order; optional bounded concurrency | | `one env` | Review and manage environment variables | | `one login` | Sign in to Infisical with your browser | | `one serve` | Inspect workspaces, manage the current account and shared credentials | | `one run [task]` | Discover and execute workspace tasks through mise | | `one exec -- ` | Execute a command with the selected project environment | +`dev`, `build`, `test`, and `lint` are task names. They all use the same shorthand: `one ` → `one run `. For example, `one dev` runs `one run dev`, and `one build -p web` runs `one run build -p web`. + Full command docs live at [1cli.dev](https://1cli.dev). ## Work With AI Assistants diff --git a/apps/docs/content/docs/en/build.md b/apps/docs/content/docs/en/build.md index 97262f5..8fea955 100644 --- a/apps/docs/content/docs/en/build.md +++ b/apps/docs/content/docs/en/build.md @@ -1,9 +1,9 @@ --- -title: one build +title: build task description: Build selected projects and their local dependencies through mise. --- -`one build` is the convenience entry point for `one run build`. Both use the same task planner, environment snapshot, mise invocation, cache, and output schema. +`one build` is shorthand for `one run build`. `build` is an ordinary task name, just like `test` or `lint`, and uses the same task parsing, flags, and help. The effective task graph comes from mise, including your overrides in `mise.toml`. ```sh one build diff --git a/apps/docs/content/docs/en/cli-overview.md b/apps/docs/content/docs/en/cli-overview.md index daab99e..9a79ce7 100644 --- a/apps/docs/content/docs/en/cli-overview.md +++ b/apps/docs/content/docs/en/cli-overview.md @@ -7,8 +7,6 @@ description: Daily commands and advanced entry points. | --- | --- | | `one create` | Create a workspace | | `one add` | Add a project | -| `one dev` | Start development | -| `one build` | Build projects | | `one env` | Manage project variables | | `one login` / `one whoami` / `one logout` | Single browser session | | `one env --global` | Discover global variable locations and environments | @@ -20,4 +18,6 @@ description: Daily commands and advanced entry points. | `one templates` | List project templates | | `one hk` | Run Git hook checks | +`dev`, `build`, `test`, and `lint` are ordinary workspace task names. `one ` is shorthand for `one run `: `one dev` runs `one run dev`, and `one build` runs `one run build`. See [tasks](/en/docs/run/) for the shared flags and task discovery. + Discover the full catalogue with `one help --all`, then read command-specific `--help`. Agents discover metadata and execution options through the CLI, without copying commands from the Dashboard. See [login and shared credentials](/en/docs/login/). diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index 562b62f..4eb2427 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -1,9 +1,9 @@ --- -title: one dev +title: dev task description: Run development tasks and their prerequisites through mise. --- -`one dev` is equivalent to `one run dev`. Both execute the effective mise task graph, including your overrides in `mise.toml`. +`one dev` is shorthand for `one run dev`. `dev` is an ordinary task name, just like `test` or `lint`, and uses the same task parsing, flags, and help. The effective task graph comes from mise, including your overrides in `mise.toml`. ```sh one dev diff --git a/apps/docs/content/docs/en/run.md b/apps/docs/content/docs/en/run.md index 561730a..58e069c 100644 --- a/apps/docs/content/docs/en/run.md +++ b/apps/docs/content/docs/en/run.md @@ -18,7 +18,7 @@ one run build --dry-run -o json ## Task shorthand -`one ` is shorthand for `one run `. For example, `one test -p api` and `one run test -p api` execute the same task. Built-in commands and aliases take precedence: `one env` opens environment management, while `one run env` executes a task named `env`. Unknown names resolve as tasks and report a missing-task error when absent. `one --help` shows One's common task flags; use `-- --help` to forward help to the underlying command. +`one ` is shorthand for `one run `. `dev`, `build`, `test`, and `lint` are ordinary task names and all follow this rule. For example, `one test -p api` and `one run test -p api` execute the same task. Built-in commands and aliases take precedence: `one env` opens environment management, while `one run env` executes a task named `env`. Unknown names resolve as tasks and report a missing-task error when absent. `one --help` shows One's common task flags; use `-- --help` to forward help to the underlying command. ## Selection and arguments diff --git a/apps/docs/content/docs/zh/build.md b/apps/docs/content/docs/zh/build.md index 76e9198..f0c8f71 100644 --- a/apps/docs/content/docs/zh/build.md +++ b/apps/docs/content/docs/zh/build.md @@ -1,9 +1,9 @@ --- -title: one build +title: build 任务 description: 通过 mise 构建项目及其本地依赖。 --- -`one build` 是 `one run build` 的便捷入口,共用任务规划、环境快照、mise 调用、缓存和输出 schema。 +`one build` 是 `one run build` 的简写。`build` 和 `test`、`lint` 一样是普通任务名,共用任务解析、参数和帮助。最终执行 mise 中生效的任务图,包括你在 `mise.toml` 中定义的覆盖。 ```sh one build diff --git a/apps/docs/content/docs/zh/cli-overview.md b/apps/docs/content/docs/zh/cli-overview.md index 53e6215..4aac4ec 100644 --- a/apps/docs/content/docs/zh/cli-overview.md +++ b/apps/docs/content/docs/zh/cli-overview.md @@ -7,8 +7,6 @@ description: One CLI 日常命令和高级入口。 | --- | --- | | `one create` | 创建工作区 | | `one add` | 添加项目 | -| `one dev` | 启动开发 | -| `one build` | 执行项目构建 | | `one env` | 查看和管理项目环境变量 | | `one login` / `one whoami` / `one logout` | 单账号浏览器会话 | | `one env --global` | 发现共享凭据位置与环境 | @@ -20,4 +18,6 @@ description: One CLI 日常命令和高级入口。 | `one templates` | 查看项目模板 | | `one hk` | 执行 Git hooks 检查 | +`dev`、`build`、`test`、`lint` 都是普通工作区任务名。`one <任务名>` 是 `one run <任务名>` 的简写:`one dev` 执行 `one run dev`,`one build` 执行 `one run build`。通用参数与任务发现见[任务管理](/zh/docs/run/)。 + 通过 `one help --all` 发现完整命令,执行前查看对应 `--help`。Agent 无需在 Dashboard 复制执行命令,可以自主读取帮助、列出目录与变量元数据,再用明确的环境和目录执行任务。详情见[登录与共享凭据](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index fdb350a..7b30f2f 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -1,9 +1,9 @@ --- -title: one dev +title: dev 任务 description: 通过 mise 运行开发任务及其前置依赖。 --- -`one dev` 等同于 `one run dev`,执行 mise 中最终生效的任务图,包括你在 `mise.toml` 中定义的覆盖。 +`one dev` 是 `one run dev` 的简写。`dev` 和 `test`、`lint` 一样是普通任务名,共用任务解析、参数和帮助。最终执行 mise 中生效的任务图,包括你在 `mise.toml` 中定义的覆盖。 ```sh one dev diff --git a/apps/docs/content/docs/zh/run.md b/apps/docs/content/docs/zh/run.md index 8ddbfdb..f16ddbe 100644 --- a/apps/docs/content/docs/zh/run.md +++ b/apps/docs/content/docs/zh/run.md @@ -18,7 +18,7 @@ one run build --dry-run -o json ## 任务简写 -`one <任务名>` 是 `one run <任务名>` 的简写。例如 `one test -p api` 与 `one run test -p api` 执行同一个任务。内置命令及其别名优先:`one env` 打开环境变量管理,`one run env` 执行名为 env 的任务。未知名称按任务解析,找不到时提示任务不存在。`one <任务名> --help` 显示 One 通用任务参数;用 `-- --help` 把帮助参数传给底层命令。 +`one <任务名>` 是 `one run <任务名>` 的简写。`dev`、`build`、`test`、`lint` 都是普通任务名,统一遵循这条规则。例如 `one test -p api` 与 `one run test -p api` 执行同一个任务。内置命令及其别名优先:`one env` 打开环境变量管理,`one run env` 执行名为 env 的任务。未知名称按任务解析,找不到时提示任务不存在。`one <任务名> --help` 显示 One 通用任务参数;用 `-- --help` 把帮助参数传给底层命令。 ## 项目选择与参数 diff --git a/packages/cli/internal/bootstrap/cli/root_test.go b/packages/cli/internal/bootstrap/cli/root_test.go index a3ae6f6..93fa060 100644 --- a/packages/cli/internal/bootstrap/cli/root_test.go +++ b/packages/cli/internal/bootstrap/cli/root_test.go @@ -160,21 +160,16 @@ func TestIsKnownSubcommand(t *testing.T) { // Every command is assembled by the composition root. for _, name := range []string{ "create", "templates", "add", "exec", "run", - // Per-domain commands (post capability-interface refactor). - "env", "dev", "build", - // configure owns the credential CRUD surface (renamed from - // `profile` to align with industry standard CLIs). + "env", "serve", "mise", "hk", "login", "whoami", "logout", "locale", "init", } { if !isKnownSubcommand(name) { t.Errorf("isKnownSubcommand(%q) = false, want true", name) } } - // Removed commands: per-domain ones replaced by `one env|container|dev|deploy`, - // `one plugins` removed entirely with the plugin concept, `one setup` - // replaced by `one configure`, and `one profile` - // renamed to `one configure`. + // Task names and removed commands are not built-in subcommands. for _, name := range []string{ + "dev", "build", "test", "lint", "doctor", "status", "unknown", "secrets", "skill", "prd", "design", "docker", "infisical", "dotenv", "procs", "compose", "k8s", "plugins", "setup", "profile", "container", "deploy", "ci", "skills", diff --git a/packages/cli/internal/bootstrap/cli/task_shorthand_test.go b/packages/cli/internal/bootstrap/cli/task_shorthand_test.go index 06af070..3a89738 100644 --- a/packages/cli/internal/bootstrap/cli/task_shorthand_test.go +++ b/packages/cli/internal/bootstrap/cli/task_shorthand_test.go @@ -7,6 +7,9 @@ import ( func TestTaskShorthandPreservesCommandBoundaries(t *testing.T) { for _, tc := range []struct{ args, want []string }{ + {[]string{"dev", "-p", "web", "--", "--port", "4300"}, []string{"run", "dev", "-p", "web", "--", "--port", "4300"}}, + {[]string{"--output=json", "build", "--dry-run"}, []string{"--output=json", "run", "build", "--dry-run"}}, + {[]string{"lint", "--help"}, []string{"run", "lint", "--help"}}, {[]string{"test", "-p", "web", "--", "-o", "result.json"}, []string{"run", "test", "-p", "web", "--", "-o", "result.json"}}, {[]string{"-o", "json", "docs:build", "--dry-run"}, []string{"-o", "json", "run", "docs:build", "--dry-run"}}, {[]string{"-ojson", "verify", "--help"}, []string{"-ojson", "run", "verify", "--help"}}, diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index ae9dd45..a548cfb 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -1,8 +1,8 @@ { "root.short": "AI Native monorepo workspace orchestrator", - "root.help": "\none — workspace development\n\nUSAGE\n one [options]\n\nEVERYDAY COMMANDS\n create Create a workspace\n add Add a project\n dev Start local development\n build Build projects\n run List and execute workspace tasks\n exec Execute a command with project variables\n env Manage environment variables\n login Sign in to Infisical in your browser\n\nCOMMON OPTIONS\n -o, --output Output format: json | yaml | text\n -h, --help Show help\n -v, --version Show version\n\nEXAMPLES\n one create demo\n one add\n one dev\n one build -p web\n one test -p web\n\nAll commands: one help --all\nTask shorthand: one = one run (built-ins take precedence)\nCommand help: one --help\n", + "root.help": "\none — workspace development\n\nUSAGE\n one [options]\n\nEVERYDAY COMMANDS\n create Create a workspace\n add Add a project\n run List and execute workspace tasks\n exec Execute a command with project variables\n env Manage environment variables\n login Sign in to Infisical in your browser\n\nCOMMON OPTIONS\n -o, --output Output format: json | yaml | text\n -h, --help Show help\n -v, --version Show version\n\nEXAMPLES\n one create demo\n one add\n one dev\n one build -p web\n one test -p web\n\nAll commands: one help --all\nTask shorthand: one = one run (built-ins take precedence)\nCommand help: one --help\n", "root.help_all_title": "one — all commands", - "help.all_intro": "All commands remain available. Everyday work usually needs only create, add, dev, build, env, and login.", + "help.all_intro": "Built-in commands are listed below. Run workspace tasks with one run , or shorthand such as one dev, one build, and one test.", "help.all_tip": "Inspect a command: one --help", "help.description": "DESCRIPTION", "help.usage": "USAGE", @@ -637,9 +637,7 @@ "tasks.result.cancelled": "Workspace task execution was cancelled.", "tasks.one_name": "Use one run [-p project] -- [arguments]. For an arbitrary command, use one exec.", "tasks.run.short": "Run named workspace tasks with mise", - "tasks.run.long": "Lists tasks with no arguments. Runs root tasks or selected project tasks with their dependencies. one is shorthand for one run ; built-in commands take precedence. Use one run for a conflicting name.", - "tasks.build.short": "Build projects and required dependencies with mise", - "tasks.build.long": "Uses the same task graph and cache as one run build. Selecting a project includes required upstream builds.", + "tasks.run.long": "Lists tasks with no arguments. Runs root tasks or selected project tasks with their dependencies. one is shorthand for one run , including dev, build, test, and lint. Built-in commands take precedence; use one run for a conflicting name.", "tasks.flag.project": "Project name or relative path (repeatable)", "tasks.flag.env": "One project environment name", "tasks.flag.cache": "Cache access: local-only, read-write, read-only, write-only, off", @@ -652,9 +650,6 @@ "tasks.cache_context_required": "Task %s enables artifact caching without the One environment fingerprint. Add one __task-input to cache.command_inputs in mise.toml.", "tasks.list_details": " Source: %s · cache configured: %s · interactive: %s", "tasks.directory_override": "Generated task %s must run in project directory %s. Remove its dir override, or define a custom run command.", - "tasks.project_flag_required": "Select projects with -p (repeatable). Pass task arguments after --.", - "tasks.dev.short": "Run development tasks with mise", - "tasks.dev.long": "Equivalent to one run dev. Runs the effective mise dev task, including upstream builds. Use -p to select projects. Multiple services use prefixed logs; use --ui raw for one interactive service.", "tasks.dev_exclusive": "Development task %s requires exclusive terminal access. Run it separately with -p, or remove interactive/raw for concurrent services.", "tasks.dev_raw_multiple": "--ui raw supports one development service. Use --ui stream for multiple services.", "tasks.dev_concurrency": "These development tasks require at least %d concurrent slots. Omit --concurrency for automatic allocation.", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 21e5214..136d60f 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -1,8 +1,8 @@ { "root.short": "AI Native 单体仓库编排器", - "root.help": "\none — 工作区开发工具\n\n用法\n one [options]\n\n日常命令\n create 创建新工作区\n add 添加项目\n dev 启动本地开发\n build 构建项目\n run 列出和执行工作区任务\n exec 在项目环境中执行命令\n env 管理环境变量\n login 在浏览器中登录 Infisical\n\n常用选项\n -o, --output 输出格式:json | yaml | text\n -h, --help 显示帮助\n -v, --version 显示版本号\n\n示例\n one create demo\n one add\n one dev\n one build -p web\n one test -p web\n\n完整命令:one help --all\n任务简写:one <任务名> = one run <任务名>(内置命令优先)\n命令帮助:one --help\n", + "root.help": "\none — 工作区开发工具\n\n用法\n one [options]\n\n日常命令\n create 创建新工作区\n add 添加项目\n run 列出和执行工作区任务\n exec 在项目环境中执行命令\n env 管理环境变量\n login 在浏览器中登录 Infisical\n\n常用选项\n -o, --output 输出格式:json | yaml | text\n -h, --help 显示帮助\n -v, --version 显示版本号\n\n示例\n one create demo\n one add\n one dev\n one build -p web\n one test -p web\n\n完整命令:one help --all\n任务简写:one <任务名> = one run <任务名>(内置命令优先)\n命令帮助:one --help\n", "root.help_all_title": "one — 完整命令", - "help.all_intro": "以下命令均保持可用;日常使用通常只需要 create、add、dev、build、env 和 login。", + "help.all_intro": "以下为内置命令。工作区任务使用 one run <任务名>,也可简写为 one dev、one build、one test 等。", "help.all_tip": "查看某个命令:one --help", "help.description": "说明", "help.usage": "用法", @@ -637,9 +637,7 @@ "tasks.result.cancelled": "工作区任务执行已取消。", "tasks.one_name": "用法:one run <任务> [-p 项目] -- [参数]。执行任意命令请使用 one exec。", "tasks.run.short": "使用 mise 执行工作区具名任务", - "tasks.run.long": "无参数时列出任务;执行根任务或指定项目任务及其依赖。one <任务名> 是 one run <任务名> 的简写,内置命令优先;名称冲突时使用 one run <任务名>。", - "tasks.build.short": "使用 mise 构建项目及其必要依赖", - "tasks.build.long": "与 one run build 共用任务图和缓存。选择项目时会包含必要的上游构建。", + "tasks.run.long": "无参数时列出任务;执行根任务或指定项目任务及其依赖。one <任务名> 是 one run <任务名> 的简写,dev、build、test、lint 等任务均适用。内置命令优先;名称冲突时使用 one run <任务名>。", "tasks.flag.project": "项目名或相对路径(可重复)", "tasks.flag.env": "One 项目环境名称", "tasks.flag.cache": "缓存访问模式:local-only、read-write、read-only、write-only、off", @@ -652,9 +650,6 @@ "tasks.cache_context_required": "任务 %s 启用了产物缓存,但未声明 One 环境指纹。请在 mise.toml 的 cache.command_inputs 中加入 one __task-input。", "tasks.list_details": " 来源:%s · 已配置缓存:%s · 交互任务:%s", "tasks.directory_override": "生成任务 %s 必须在项目目录 %s 执行。请移除 dir 覆盖,或定义自有 run 命令。", - "tasks.project_flag_required": "请使用 -p <项目> 选择项目,可重复指定;任务参数放在 -- 后。", - "tasks.dev.short": "通过 mise 运行开发任务", - "tasks.dev.long": "等同于 one run dev,执行 mise 中最终生效的 dev 任务及前置构建。用 -p 选择项目。多服务使用带前缀的日志;单个交互服务可使用 --ui raw。", "tasks.dev_exclusive": "开发任务 %s 需要独占终端。请用 -p 单独运行,或移除 interactive/raw 后并发启动服务。", "tasks.dev_raw_multiple": "--ui raw 仅支持一个开发服务;多个服务请使用 --ui stream。", "tasks.dev_concurrency": "这些开发任务至少需要 %d 个并发位置。省略 --concurrency 可自动分配。", diff --git a/packages/cli/internal/transport/cobra/run/cmd.go b/packages/cli/internal/transport/cobra/run/cmd.go index 4a271e9..b7e804c 100644 --- a/packages/cli/internal/transport/cobra/run/cmd.go +++ b/packages/cli/internal/transport/cobra/run/cmd.go @@ -19,33 +19,22 @@ import ( func Commands(loaders *secrets.Registry, provider runtimeport.Provider) []*cobra.Command { service := tasks.Service{Provider: provider, Loaders: loaders} - return []*cobra.Command{command("run", service), command("build", service), command("dev", service), internal(false), internal(true), serviceWorker(service)} + return []*cobra.Command{command(service), internal(false), internal(true), serviceWorker(service)} } -func command(kind string, service tasks.Service) *cobra.Command { +func command(service tasks.Service) *cobra.Command { opts := tasks.Options{Jobs: 1, Cache: "local-only", UI: "auto"} var list, dry bool - use := "run [task]" - if kind != "run" { - use = kind - } - cmd := &cobra.Command{Use: use, Args: cobra.ArbitraryArgs, Example: " one run\n one run build -p web\n one run test -p api\n one run build --dry-run", RunE: func(cmd *cobra.Command, args []string) (resultErr error) { + cmd := &cobra.Command{Use: "run [task]", Args: cobra.ArbitraryArgs, Example: " one run\n one run build -p web\n one run test -p api\n one run build --dry-run", RunE: func(cmd *cobra.Command, args []string) (resultErr error) { dash := cmd.ArgsLenAtDash() if dash >= 0 { opts.Arguments = args[dash:] args = args[:dash] } - if kind != "run" { - opts.Name = kind - if len(args) > 0 { - return i18n.Errorf("tasks.project_flag_required") - } - } else { - if len(args) > 1 { - return i18n.Errorf("tasks.one_name") - } - if len(args) == 1 { - opts.Name = args[0] - } + if len(args) > 1 { + return i18n.Errorf("tasks.one_name") + } + if len(args) == 1 { + opts.Name = args[0] } w, err := execution.ResolveWorkspace(cmd.Context()) if err != nil { @@ -112,9 +101,6 @@ func command(kind string, service tasks.Service) *cobra.Command { } return err }} - if kind != "run" { - cmd.Example = " one " + kind + "\n one " + kind + " -p web -p api\n one " + kind + " -p web --dry-run" - } cmd.Flags().StringArrayVarP(&opts.Projects, "project", "p", nil, "") cmd.Flags().StringVar(&opts.Environment, "env", "", "") cmd.Flags().StringVar(&opts.Cache, "cache", "local-only", "") @@ -126,8 +112,8 @@ func command(kind string, service tasks.Service) *cobra.Command { for _, name := range []string{"project", "env", "cache", "ui", "concurrency", "force", "dry-run", "list"} { i18n.MarkFlagUsage(cmd, name, "tasks.flag."+strings.ReplaceAll(name, "-", "_")) } - i18n.MarkShort(cmd, "tasks."+kind+".short") - i18n.MarkLong(cmd, "tasks."+kind+".long") + i18n.MarkShort(cmd, "tasks.run.short") + i18n.MarkLong(cmd, "tasks.run.long") return cmd } func internal(fingerprint bool) *cobra.Command { diff --git a/packages/cli/testdata/reference/help/build.txt b/packages/cli/testdata/reference/help/build.txt deleted file mode 100644 index 816bdf5..0000000 --- a/packages/cli/testdata/reference/help/build.txt +++ /dev/null @@ -1,25 +0,0 @@ - -DESCRIPTION -Build projects and required dependencies with mise - -USAGE - one build [flags] - -EXAMPLES - one build - one build -p web -p api - one build -p web --dry-run - -TIPS -Uses the same task graph and cache as one run build. Selecting a project includes required upstream builds. - -COMMON OPTIONS - --cache Cache access: local-only, read-write, read-only, write-only, off - --concurrency Maximum parallel tasks (dev allocates automatically when omitted) - --dry-run Preview tasks without preparing tools, dependencies, secrets, or caches - --env One project environment name - --force Bypass freshness checks; combine with --cache off to ensure execution - --list List available tasks - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - -p, --project Project name or relative path (repeatable) - --ui Output mode: auto, stream, raw diff --git a/packages/cli/testdata/reference/help/dev.txt b/packages/cli/testdata/reference/help/dev.txt deleted file mode 100644 index e3d33ac..0000000 --- a/packages/cli/testdata/reference/help/dev.txt +++ /dev/null @@ -1,25 +0,0 @@ - -DESCRIPTION -Run development tasks with mise - -USAGE - one dev [flags] - -EXAMPLES - one dev - one dev -p web -p api - one dev -p web --dry-run - -TIPS -Equivalent to one run dev. Runs the effective mise dev task, including upstream builds. Use -p to select projects. Multiple services use prefixed logs; use --ui raw for one interactive service. - -COMMON OPTIONS - --cache Cache access: local-only, read-write, read-only, write-only, off - --concurrency Maximum parallel tasks (dev allocates automatically when omitted) - --dry-run Preview tasks without preparing tools, dependencies, secrets, or caches - --env One project environment name - --force Bypass freshness checks; combine with --cache off to ensure execution - --list List available tasks - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - -p, --project Project name or relative path (repeatable) - --ui Output mode: auto, stream, raw diff --git a/packages/cli/testdata/reference/help/root.txt b/packages/cli/testdata/reference/help/root.txt index 7c46a35..dc521a0 100644 --- a/packages/cli/testdata/reference/help/root.txt +++ b/packages/cli/testdata/reference/help/root.txt @@ -7,8 +7,6 @@ USAGE EVERYDAY COMMANDS create Create a workspace add Add a project - dev Start local development - build Build projects run List and execute workspace tasks exec Execute a command with project variables env Manage environment variables diff --git a/packages/cli/testdata/reference/help/run.txt b/packages/cli/testdata/reference/help/run.txt index 31a44df..8f7bf23 100644 --- a/packages/cli/testdata/reference/help/run.txt +++ b/packages/cli/testdata/reference/help/run.txt @@ -12,7 +12,7 @@ EXAMPLES one run build --dry-run TIPS -Lists tasks with no arguments. Runs root tasks or selected project tasks with their dependencies. one is shorthand for one run ; built-in commands take precedence. Use one run for a conflicting name. +Lists tasks with no arguments. Runs root tasks or selected project tasks with their dependencies. one is shorthand for one run , including dev, build, test, and lint. Built-in commands take precedence; use one run for a conflicting name. COMMON OPTIONS --cache Cache access: local-only, read-write, read-only, write-only, off diff --git a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go index c973bdb..a1fc5e7 100644 --- a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go +++ b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go @@ -45,32 +45,41 @@ func TestSnapshot_E2E_HelpDailyAndCompleteCatalogues(t *testing.T) { tmp := t.TempDir() isolateHome(t, tmp) - daily, stderr, code := runBinaryIn(t, tmp, "--help") - if code != 0 || stderr != "" { - t.Fatalf("one --help failed: exit=%d stderr=%q", code, stderr) - } - for _, command := range []string{"create", "add", "dev", "build", "env", "login", "run", "exec"} { - if !strings.Contains(daily, " "+command) { - t.Errorf("daily help missing %q:\n%s", command, daily) - } - } - for _, command := range []string{"ci", "templates", "serve"} { - if strings.Contains(daily, "\n "+command) { - t.Errorf("daily help should not advertise advanced command %q:\n%s", command, daily) - } - } - - all, stderr, code := runBinaryIn(t, tmp, "help", "--all") - if code != 0 || stderr != "" { - t.Fatalf("one help --all failed: exit=%d stderr=%q", code, stderr) - } - for _, command := range []string{"create", "add", "dev", "build", "env", "login", "run", "exec", "templates", "serve"} { - if !strings.Contains(all, " "+command) { - t.Errorf("complete help missing %q:\n%s", command, all) - } - } - if strings.Contains(all, "\n skills") || strings.Contains(all, "\n ci ") { - t.Errorf("complete help exposes removed commands:\n%s", all) + for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { + t.Run(locale, func(t *testing.T) { + t.Setenv("LC_ALL", locale) + daily, stderr, code := runBinaryIn(t, tmp, "--help") + if code != 0 || stderr != "" { + t.Fatalf("one --help failed: exit=%d stderr=%q", code, stderr) + } + for _, command := range []string{"create", "add", "env", "login", "run", "exec"} { + if !strings.Contains(daily, "\n "+command+" ") { + t.Errorf("daily help missing %q:\n%s", command, daily) + } + } + for _, command := range []string{"templates", "serve"} { + if strings.Contains(daily, "\n "+command+" ") { + t.Errorf("daily help should not advertise advanced command %q:\n%s", command, daily) + } + } + + all, stderr, code := runBinaryIn(t, tmp, "help", "--all") + if code != 0 || stderr != "" { + t.Fatalf("one help --all failed: exit=%d stderr=%q", code, stderr) + } + for _, command := range []string{"create", "add", "env", "login", "logout", "whoami", "run", "exec", "templates", "serve", "init", "locale", "mise", "hk"} { + if !strings.Contains(all, "\n "+command+" ") { + t.Errorf("complete help missing %q:\n%s", command, all) + } + } + for _, command := range []string{"dev", "build", "test", "lint", "ci", "skills"} { + for _, help := range []string{daily, all} { + if strings.Contains(help, "\n "+command+" ") { + t.Errorf("help advertises task or removed command %q as built-in:\n%s", command, help) + } + } + } + }) } } diff --git a/packages/cli/tests/e2e/task_shortcuts_test.go b/packages/cli/tests/e2e/task_shortcuts_test.go index 8a6fa1f..be0e323 100644 --- a/packages/cli/tests/e2e/task_shortcuts_test.go +++ b/packages/cli/tests/e2e/task_shortcuts_test.go @@ -20,7 +20,16 @@ raw_args = true [tasks.env] run = 'echo TASK_ENV' [tasks.dev] -run = 'echo ROOT_DEV_OVERRIDE' +run = 'echo ROOT_TASK_OVERRIDE' +depends = [] +[tasks.build] +run = 'echo ROOT_TASK_OVERRIDE' +depends = [] +[tasks.test] +run = 'echo ROOT_TASK_OVERRIDE' +depends = [] +[tasks.lint] +run = 'echo ROOT_TASK_OVERRIDE' depends = [] `) buildWrite(t, root, "hello.sh", "printf '<%s>\\n' \"$@\"\n") @@ -42,19 +51,21 @@ depends = [] if code != 0 || !strings.Contains(logs, "TASK_ENV") { t.Fatal(logs) } - for _, args := range [][]string{{"dev"}, {"run", "dev"}} { - _, logs, code = runBinaryIn(t, root, append(args, "-o", "json")...) - if code != 0 || !strings.Contains(logs, "ROOT_DEV_OVERRIDE") { - t.Fatal(logs) + for _, name := range []string{"dev", "build", "test", "lint"} { + for _, args := range [][]string{{name}, {"run", name}} { + _, logs, code = runBinaryIn(t, root, append(args, "-o", "json")...) + if code != 0 || !strings.Contains(logs, "ROOT_TASK_OVERRIDE") { + t.Fatal(args, code, logs) + } } } } func TestE2E_TaskShortcutPreviewAndHelpAreStatic(t *testing.T) { root := buildFixture(t, true) - buildWrite(t, root, "packages/lib/package.json", `{"name":"@build/lib","scripts":{"dev":"sh dev.sh","test":"sh test.sh","build":"sh build.sh"}}`) + buildWrite(t, root, "packages/lib/package.json", `{"name":"@build/lib","scripts":{"dev":"sh dev.sh","test":"sh test.sh","build":"sh build.sh","lint":"sh lint.sh"}}`) t.Setenv("ONE_MISE_BINARY", filepath.Join(root, "missing-mise")) - for _, name := range []string{"dev", "build", "test"} { + for _, name := range []string{"dev", "build", "test", "lint"} { short, logs, code := runBinaryIn(t, root, name, "-p", "lib", "--dry-run", "-o", "json") if code != 0 { t.Fatal(name, logs) @@ -67,12 +78,21 @@ func TestE2E_TaskShortcutPreviewAndHelpAreStatic(t *testing.T) { t.Fatalf("%s preview differs", name) } } + outside := t.TempDir() for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { t.Setenv("LC_ALL", locale) - for _, name := range []string{"dev", "build", "test"} { - out, logs, code := runBinaryIn(t, root, name, "--help") - if code != 0 || !strings.Contains(out, "--project") || strings.Contains(out, "tasks.") { - t.Fatal(out, logs) + want, logs, code := runBinaryIn(t, outside, "run", "--help") + if code != 0 || !strings.Contains(want, "--project") || strings.Contains(want, "tasks.") { + t.Fatal(want, logs) + } + for _, dir := range []string{root, outside} { + for _, name := range []string{"dev", "build", "test", "lint"} { + for _, args := range [][]string{{name, "--help"}, {"run", name, "--help"}} { + out, logs, code := runBinaryIn(t, dir, args...) + if code != 0 || out != want { + t.Fatalf("%s %v help differs from run: exit=%d out=%s logs=%s", locale, args, code, out, logs) + } + } } } } diff --git a/packages/cli/tools/verify-cli-references/main.go b/packages/cli/tools/verify-cli-references/main.go index 7f833f0..ed9c730 100644 --- a/packages/cli/tools/verify-cli-references/main.go +++ b/packages/cli/tools/verify-cli-references/main.go @@ -1,6 +1,6 @@ // verify-cli-references walks every prose / template doc in the repo and // asserts that any `one ` reference inside a code-span or -// fenced code block names a real, currently-registered subcommand. +// fenced code block names a registered command or a documented task shorthand. // // Run via Taskfile: `task verify-cli-references`. Exits non-zero with a // list of file:line offenders on any drift. @@ -54,17 +54,24 @@ func run() error { if err != nil { return err } - var problems []string + contents := make(map[string][]byte, len(files)) for _, f := range files { b, err := os.ReadFile(f) if err != nil { return fmt.Errorf("read %s: %w", f, err) } - problems = append(problems, scanFile(f, b, valid)...) + contents[f] = b + } + // Task guides declare names with one run . Their shorthand may + // also appear in quick starts and template READMEs elsewhere in the docs. + references := collectReferences(valid, contents) + var problems []string + for _, f := range files { + problems = append(problems, scanFile(f, contents[f], references)...) } if len(problems) > 0 { return fmt.Errorf( - "found unknown `one ` references in docs:\n %s\n\nFix one of:\n - Rename to a registered command (see `one --help`).\n - If the reference is intentional (migration guide / changelog), wrap the section in:\n \n ... section with deprecated command names ...\n ", + "found unknown `one ` references in docs:\n %s\n\nFix one of:\n - Rename to a registered command (see `one --help`) or a documented task (one run ).\n - If the reference is intentional (migration guide / changelog), wrap the section in:\n \n ... section with deprecated command names ...\n ", strings.Join(problems, "\n "), ) } @@ -174,17 +181,23 @@ var ( codeSpanRE = regexp.MustCompile("`([^`\n]+)`") ) -func scanFile(path string, content []byte, valid map[string]struct{}) []string { - // A task demonstrated with the explicit run syntax can also be called - // through shorthand in this document. Keep checking unrelated typos. - local := make(map[string]struct{}, len(valid)) - for name := range valid { - local[name] = struct{}{} +// collectReferences adds documented task names without changing the command +// catalogue. Undeclared names still fail the reference check to catch typos. +func collectReferences(commands map[string]struct{}, documents map[string][]byte) map[string]struct{} { + valid := make(map[string]struct{}, len(commands)) + for name := range commands { + valid[name] = struct{}{} } - for _, match := range regexp.MustCompile(`\bone run ([a-z][a-z0-9:-]*)`).FindAllSubmatch(content, -1) { - local[string(match[1])] = struct{}{} + taskRE := regexp.MustCompile(`\bone run ([a-z][a-z0-9:-]*)`) + for _, content := range documents { + for _, match := range taskRE.FindAllSubmatch(content, -1) { + valid[string(match[1])] = struct{}{} + } } - valid = local + return valid +} + +func scanFile(path string, content []byte, valid map[string]struct{}) []string { var problems []string ignore := false inFence := false @@ -227,7 +240,7 @@ func scanFile(path string, content []byte, valid map[string]struct{}) []string { cmd := m[1] if _, ok := valid[cmd]; !ok { problems = append(problems, fmt.Sprintf( - "%s:%d: `one %s …` — `%s` is not a registered subcommand", + "%s:%d: `one %s …` — `%s` is not a registered command or documented task", path, i+1, cmd, cmd, )) } diff --git a/packages/cli/tools/verify-cli-references/main_test.go b/packages/cli/tools/verify-cli-references/main_test.go new file mode 100644 index 0000000..03e8fd5 --- /dev/null +++ b/packages/cli/tools/verify-cli-references/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestTaskShorthandReferencesAcrossDocuments(t *testing.T) { + root := &cobra.Command{Use: "one"} + root.AddCommand(&cobra.Command{Use: "run"}, &cobra.Command{Use: "env"}) + commands := collectCommands(root) + documents := map[string][]byte{ + "tasks.md": []byte("`one run dev`, `one run build`, `one run lint`, `one run docs:build`"), + "quick-start.md": []byte("```sh\none dev -- --port 4300\none build\none lint\none docs:build\none env\n```"), + } + valid := collectReferences(commands, documents) + for path, content := range documents { + if problems := scanFile(path, content, valid); len(problems) != 0 { + t.Fatal(problems) + } + } + for _, name := range []string{"dev", "build", "lint", "docs:build"} { + if _, ok := commands[name]; ok { + t.Fatalf("task %q was added to the built-in command catalogue", name) + } + } + problems := scanFile("typo.md", []byte("`one buidl`"), valid) + if len(problems) != 1 || !strings.Contains(problems[0], "buidl") { + t.Fatalf("expected a reference error for undeclared task typo: %v", problems) + } +} diff --git a/packages/cli/tools/verify-help/main.go b/packages/cli/tools/verify-help/main.go index a4dd5d3..62ad224 100644 --- a/packages/cli/tools/verify-help/main.go +++ b/packages/cli/tools/verify-help/main.go @@ -7,7 +7,7 @@ // Two checks: // // - **help catalogue completeness.** The concise root help deliberately -// lists only the seven everyday commands. `one help --all` is generated +// lists only the curated everyday commands. `one help --all` is generated // from the Cobra tree and must contain every registered top-level // command. // @@ -50,7 +50,7 @@ func main() { fmt.Fprintf(os.Stderr, " %s\n", p) } fmt.Fprintln(os.Stderr, "\nFix one of:") - fmt.Fprintln(os.Stderr, " - Keep root help limited to the seven everyday commands and keep `one help --all` complete.") + fmt.Fprintln(os.Stderr, " - Keep root help limited to the curated everyday commands and keep `one help --all` complete.") fmt.Fprintln(os.Stderr, " - Update the Example / Long text in the offending cmd.go to use a flag that actually exists.") fmt.Fprintln(os.Stderr, " - Re-run with UPDATE_SNAPSHOTS=1 if you have also intentionally changed help text:") fmt.Fprintln(os.Stderr, " UPDATE_SNAPSHOTS=1 go test ./tests/e2e/ -run TestHelpSnapshots") @@ -124,7 +124,7 @@ func checkRootHelp(root *cobra.Command) []string { } want := map[string]bool{ - "create": true, "add": true, "dev": true, "build": true, + "create": true, "add": true, "env": true, "login": true, "run": true, "exec": true, } @@ -189,7 +189,7 @@ var flagInExampleRE = regexp.MustCompile(`--([a-z][a-z0-9-]*)`) // We anchor on `one ` so prose mentioning the bare `--flag` outside // a command line is ignored. Continuation lines ending with `\` are // joined back together before scanning. -var invocationRE = regexp.MustCompile(`(^|\s)one\s+([a-z][a-z0-9/-]*(?:\s+[a-z][a-z0-9/-]*)*)`) +var invocationRE = regexp.MustCompile(`(^|\s)one\s+([a-z][a-z0-9:/-]*(?:\s+[a-z][a-z0-9:/-]*)*)`) // checkExampleFlags walks every command, scans its Example + Long // text for command invocations, resolves each invocation to a real @@ -254,7 +254,7 @@ func scanInvocations(root, owner *cobra.Command, field, text string) []string { } // The first capture group can include the leading whitespace; // strip and split into command-path tokens. invocationRE - // already limited the capture to [a-z0-9/-] runs separated by + // already limited the capture to [a-z0-9:/-] runs separated by // whitespace, so URL fragments / placeholders / flags can't // appear here. We DO keep slashes — configurecmd's leaf // commands have names like "env/infisical". @@ -273,8 +273,10 @@ func scanInvocations(root, owner *cobra.Command, field, text string) []string { continue } - // Extract --flag tokens from the FULL line so we catch flags - // even after the command path. + // Task arguments after -- belong to the underlying command. + if i := strings.Index(line, " -- "); i >= 0 { + line = line[:i] + } flagSet := collectFlagSet(resolved) for _, fm := range flagInExampleRE.FindAllStringSubmatch(line, -1) { flag := fm[1] @@ -301,7 +303,7 @@ func scanInvocations(root, owner *cobra.Command, field, text string) []string { // (`one container build`) rather than returning nil. Flags on // `user-api` should be validated against `container build`. // -// Returns nil only when the very first token doesn't match any child. +// Unregistered task names use run flags, matching the CLI task shorthand. func resolveCommand(root *cobra.Command, pathTokens []string) *cobra.Command { if len(pathTokens) == 0 { return nil @@ -336,6 +338,11 @@ func resolveCommand(root *cobra.Command, pathTokens []string) *cobra.Command { matched = true } if !matched { + for _, child := range root.Commands() { + if child.Name() == "run" { + return child + } + } return nil } return cur diff --git a/packages/cli/tools/verify-help/main_test.go b/packages/cli/tools/verify-help/main_test.go index 57a1355..b4913e6 100644 --- a/packages/cli/tools/verify-help/main_test.go +++ b/packages/cli/tools/verify-help/main_test.go @@ -18,12 +18,22 @@ func TestExampleFlagsRespectResolvedCommandParsing(t *testing.T) { hk := &cobra.Command{Use: "hk", DisableFlagParsing: true} configure := &cobra.Command{Use: "configure"} configure.Flags().Bool("dry-run", false, "") - root.AddCommand(hk, configure) + run := &cobra.Command{Use: "run [task]"} + run.Flags().String("project", "", "") + root.AddCommand(hk, configure, run) for _, tt := range []struct { name, example string owner *cobra.Command wantProblems int }{ + {"dev shorthand", "one dev --project web", run, 0}, + {"build shorthand", "one build --project web", run, 0}, + {"test shorthand", "one test --project web", run, 0}, + {"lint shorthand", "one lint --project web", run, 0}, + {"namespaced shorthand", "one docs:build --project docs", run, 0}, + {"unknown task flag", "one dev --typo", run, 1}, + {"task arguments", "one dev --project web -- --port 4300", run, 0}, + {"built-in precedence", "one configure --project web", run, 1}, {"native flag", "one configure --dry-run", configure, 0}, {"unknown native flag", "one configure --typo", configure, 1}, {"forwarded flag", "one hk check --all", hk, 0}, From 50997c5e7d208f5d7d7cdfa542d9d5fc69aefcee Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Mon, 28 Sep 2026 23:05:47 +0800 Subject: [PATCH 09/16] fix(cli)!: centralize workspace tasks in root mise config Generate namespaced project tasks only in the root mise.toml while keeping package scripts and Taskfiles as command sources. Preserve project execution context, caching, and native bootstrap workflows. Register this repository as a One CLI workspace, update bilingual documentation and regression coverage, and remove the unused SEO manifest. Validation: mise run check; real mise task-context integration; Dashboard service start/stop and one dev HTTP smoke checks. BREAKING CHANGE: generated project tasks now use //:project:task names in the root mise.toml instead of per-project mise configurations. --- CONTRIBUTING.md | 48 +- README.md | 33 +- apps/docs/content/docs/en/add.md | 2 +- apps/docs/content/docs/en/create.md | 2 +- apps/docs/content/docs/en/dev.md | 2 +- apps/docs/content/docs/en/run.md | 23 +- apps/docs/content/docs/zh/add.md | 2 +- apps/docs/content/docs/zh/create.md | 2 +- apps/docs/content/docs/zh/dev.md | 2 +- apps/docs/content/docs/zh/run.md | 21 +- mise.toml | 357 ++++ one.manifest.json | 52 + .../modules/creation/languages_test.go | 9 +- .../internal/modules/miseconfig/cache_test.go | 10 +- .../cli/internal/modules/miseconfig/config.go | 22 +- .../modules/miseconfig/config_test.go | 45 +- .../internal/modules/miseconfig/trust_test.go | 14 +- packages/cli/internal/modules/tasks/native.go | 9 +- packages/cli/internal/modules/tasks/plan.go | 206 +- .../cli/internal/modules/tasks/plan_test.go | 22 +- .../internal/platform/i18n/locales/en-US.json | 2 +- .../internal/platform/i18n/locales/zh-CN.json | 2 +- .../cli/testdata/reference/help/init_mise.txt | 2 +- packages/cli/tests/e2e/build_test.go | 4 +- packages/cli/tests/e2e/mise_runtime_test.go | 13 +- packages/cli/tests/e2e/mise_trust_test.go | 16 +- .../tests/e2e/repository_workspace_test.go | 69 + .../cli/tests/e2e/task_terminal_unix_test.go | 3 +- packages/cli/tests/e2e/tasks_cache_test.go | 43 +- seo.manifest.json | 1879 ----------------- 30 files changed, 811 insertions(+), 2105 deletions(-) create mode 100644 one.manifest.json create mode 100644 packages/cli/tests/e2e/repository_workspace_test.go delete mode 100644 seo.manifest.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d85072c..242aead 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ brew install mise # macOS;其他系统先安装 mise git clone https://github.com/1cli-team/one-cli cd one-cli -mise trust +mise trust # 信任当前 checkout 的根和项目配置 mise install # 安装 mise.toml 固定的工具版本 mise run install # 打包 Dashboard + CLI,再创建本地启动器 one --version # 验证装好 @@ -27,26 +27,44 @@ one --version # 验证装好 ## 日常开发 -跨项目命令通过根 mise.toml 调度,Go 子项目使用自己的 Taskfile: +仓库本身也是 One CLI 工作区,`one.manifest.json` 登记了四个项目: + +| 项目 | 目录 | 用途 | +|---|---|---| +| `dashboard` | `apps/dashboard` | React / Vite 管理界面 | +| `docs` | `apps/docs` | Next.js / Fumadocs 文档站 | +| `cli` | `packages/cli` | Go CLI 和公开 Go 包 | +| `kernel` | `packages/kernel` | 共享 Go 内核 | + +`packages/templates` 和测试 fixture 是源码素材,不登记为项目。`packages/cli` 保留现有路径,因为公开 Go 包的 module path 已包含该目录。 + +完成首次安装后,可以直接用 One CLI 开发自身: + +```bash +one # 查看当前工作区 +one run # 查看根任务和项目任务 +one dev # Go Dashboard API + Vite UI,使用原有开发 fixture +one dev -p docs # 文档站:http://localhost:3000 +one build -p cli # 准备嵌入资源并构建 CLI +one test -p kernel # 测试共享 Go 内核 +one run check # 完整仓库检查;也可简写 one check +one serve # 在 Dashboard 中管理当前真实仓库 +``` + +单独运行 `one dev -p dashboard` 只启动 Vite 前端,需要在另一终端运行 `one dev -p cli` 提供 API;通常直接使用联合任务 `one dev`。`one serve` 管理当前真实仓库,开发 API 仍使用测试 fixture。构建、测试和开发不需要绑定 Infisical。 + +根 `mise.toml` 继续维护原生任务,供首次安装、CI 和 Git hooks 使用: ```bash -mise tasks ls # 查看可用任务 -mise run install-deps # 安装根 Node workspace 的锁定依赖 -mise run dev # 同时启动 Go Dashboard API + Vite UI -mise run check # 在当前操作系统运行 monorepo 验证入口 -pnpm check # 根目录快捷入口,等价于 mise run check -mise run hooks:install # 为当前 checkout 启用提交前 PR gate +mise run install-deps # 安装锁定的 Node workspace 依赖 +mise run check # 完整仓库检查 mise run build # 编译到 packages/cli/bin/one mise run test # Go race 测试 + Dashboard 测试 -mise run vet # go vet -mise run fmt # gofmt -mise run install # 打包 + 本地启动器 -mise run pre-push # 推前必跑(含上面所有 + verify-docs) +mise run install # 打包并安装本地启动器,无需预先安装 one +mise run pre-push # 推送前检查,包含 race 测试 ``` -`build` / `test` / `vet` 都隐式依赖 `sync-bundled` + `sync-web`,所以你不用 -手动跑这两个——除非要让 gopls 立刻看到 `packages/templates/` 或 `apps/dashboard/` -的改动。 +项目任务来自各自的 `package.json` 或 `Taskfile.yml`,只在根 `mise.toml` 登记 `cli:build`、`dashboard:dev` 这样的任务入口,并通过 `dir` 指定子项目目录。根文件也声明 Dashboard、模板和嵌入资源的前置步骤,CLI 测试会先构建 E2E 使用的二进制。新增或修改任务目录后,运行 `one init mise` 同步这些受版本控制的配置。 ## 提交流程 diff --git a/README.md b/README.md index 93d2913..c1a1fad 100644 --- a/README.md +++ b/README.md @@ -133,23 +133,42 @@ If you want to work on One CLI itself, the repository is organized like this: | Path | Purpose | |---|---| -| `packages/cli` | The One CLI app | +| `one.manifest.json` | The four projects managed by One CLI itself | +| `packages/cli` | The One CLI app and its public Go packages | +| `packages/kernel` | Shared Go kernel | | `packages/templates` | Starters used by `one add` | | `mise.toml` | Workspace scheduling, tools, and artifact cache declarations | | `apps/docs` | Documentation website | | `apps/dashboard` | Local workspace, account, and global-variable Dashboard opened by `one serve` | | `assets` | Brand assets, including the logo | -Common contributor commands: +This repository is also a One CLI workspace: `dashboard`, `docs`, `cli`, and `kernel`. Template directories under `packages/templates` and test fixtures are source assets, not registered projects. `packages/cli` keeps its existing location because it also exports Go packages with that module path. + +Bootstrap a fresh checkout without requiring an installed `one`: ```bash -pnpm install -mise run check -mise run build -mise run test -mise run verify-docs +mise trust +mise install +mise run install ``` +Then use the workspace commands: + +```bash +one # Inspect this workspace +one run # List root and project tasks +one dev # Dashboard API + Vite UI, using the development fixture +one dev -p docs # Documentation at http://localhost:3000 +one build -p cli # Prepare embedded resources and build the CLI +one test -p kernel # Test the shared Go kernel +one run check # The complete repository gate; one check is shorthand +one serve # Manage this repository in the Dashboard +``` + +`one dev -p dashboard` starts the Vite UI; run `one dev -p cli` in another terminal for its API, or use the combined `one dev` task. `one serve` opens this repository as a real workspace, while the contributor development API uses the existing test fixture. No Infisical binding is required to build, test, or start these projects. + +Root tasks remain native mise commands, so CI and first-time installation can still use `mise run build`, `mise run check`, and `mise run install`. Project tasks come from package scripts and Taskfiles. Only the root `mise.toml` is used: project tasks are namespaced as `cli:build` or `dashboard:dev` and select their directory with `dir`. CLI tasks declare their embedded-resource prerequisites there; CLI tests also build the binary used by E2E tests. Run `one init mise` after changing the project task catalogue to refresh the tracked task adapters. + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. ## Documentation diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md index e6d6c22..90e74d1 100644 --- a/apps/docs/content/docs/en/add.md +++ b/apps/docs/content/docs/en/add.md @@ -5,7 +5,7 @@ description: Add a templated project to an existing workspace. `one add` selects a technology stack, writes a locally developable project into the workspace, and registers it in the manifest. CI and deployment remain unconfigured by default. -When hk is enabled, `one add` updates the default language checks in `.config/hk.pkl`, preserving user edits and comments. Workspace and project tasks use `mise.toml`. See [hooks](/en/docs/hk/) and [tasks](/en/docs/run/) for configuration details. +When hk is enabled, `one add` updates the default language checks in `.config/hk.pkl`, preserving user edits and comments. Workspace and project tasks share the root `mise.toml`; project directories retain only their native command files. See [hooks](/en/docs/hk/) and [tasks](/en/docs/run/) for configuration details. There are two entry points: diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md index 4ceeeb1..cdbbfcb 100644 --- a/apps/docs/content/docs/en/create.md +++ b/apps/docs/content/docs/en/create.md @@ -43,7 +43,7 @@ one create my-app --yes | Capability | Default | Behavior | |---|---|---| | Environment variables | Unbound | Bind Infisical when variables are needed; otherwise inherit the shell environment | -| Tool environment | mise | Creates root `mise.toml`; `one add` creates each project’s `mise.toml` | +| Tool environment | mise | Creates root `mise.toml`; `one add` registers project tasks in that same file | | Git checks | hk | Creates `.config/hk.pkl` and installs local Git launchers | | Local development | `one dev` | Runs the development task graph through mise | diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index 4eb2427..b5adb83 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -17,7 +17,7 @@ Without `-p`, One invokes the root `dev` task. The generated aggregate includes ## Commands and overrides -Generated adapters use `projects[].dev.command` when set, otherwise the project's native `dev` script or Taskfile task. Project `mise.toml` can override the task; root `mise.toml` can replace the workspace aggregate. Custom mise commands use mise's environment. Generated adapters receive One's frozen project environment. +Generated adapters use `projects[].dev.command` when set, otherwise the project's native `dev` script or Taskfile task. The root `mise.toml` can override a project task such as `web:dev` or replace the workspace aggregate. Custom mise commands use mise's environment. Generated adapters receive One's frozen project environment. ## Dependency preparation diff --git a/apps/docs/content/docs/en/run.md b/apps/docs/content/docs/en/run.md index 58e069c..e171f32 100644 --- a/apps/docs/content/docs/en/run.md +++ b/apps/docs/content/docs/en/run.md @@ -30,20 +30,18 @@ Without `-p`, a task selects the workspace aggregate. Repeat `-p` to select proj ## Configuration -One writes tool versions, task adapters, aggregates, and local Node build dependencies into `mise.toml` at the workspace and project roots. Edit these files directly to add tasks or adjust settings. Running tasks, adding projects, or calling `one init mise` adds missing configuration and updates generated fields that you have not changed. Custom fields and comments are preserved. Root `dev`, `build`, `check`, and `test` aggregate only available project tasks; `ci` combines build, check, and test. +One writes tool versions, task adapters, aggregates, and local Node build dependencies into a single `mise.toml` at the workspace root. Project directories keep their native command files; One does not generate project-level mise configurations. Edit the root file directly to add tasks or adjust settings. Running tasks, adding projects, or calling `one init mise` adds missing configuration and updates generated fields that you have not changed. Custom fields and comments are preserved. Root `dev`, `build`, `check`, and `test` aggregate only available project tasks; `ci` combines build, check, and test. -Keep the `# one:managed-v1` comment at the end of each file. It records the last generated fields for incremental updates. If you and One change the same field to different values, One reports `MISE_CONFIG_CONFLICT` with the field name. Adding comments, custom tasks, or changing defaults does not trigger a whole-file ownership error. +Keep the `# one:managed-v1` comment at the end of the file. It records the last generated fields for incremental updates. If you and One change the same field to different values, One reports `MISE_CONFIG_CONFLICT` with the field name. Adding comments, custom tasks, or changing defaults does not trigger a whole-file ownership error. ```text workspace/ one.manifest.json - mise.toml # Tool versions, project directories, aggregate tasks + mise.toml # Tools, project task adapters, aggregates, cache settings .config/hk.pkl # Workspace Git checks apps/web/ - mise.toml # Project tasks and cache settings package.json # pnpm scripts services/api/ - mise.toml # Project tools and tasks Taskfile.yml # Go project commands ``` @@ -54,7 +52,7 @@ one init mise --dry-run -o json one init mise ``` -Project tasks use canonical names such as `//apps/web:build`. Local Node dependencies with a build task run before dependent build, check, test, typecheck, and dev tasks. Go resolves module dependencies through `go.work` and its compiler. New Node workspaces use pnpm exclusively. +Project tasks use root names such as `web:build` (canonical name `//:web:build`), with `dir = "apps/web"`. Tool versions are pinned at the root; Go defaults to the highest version required by workspace modules. Local Node dependencies with a build task run before dependent build, check, test, typecheck, and dev tasks. Go resolves module dependencies through `go.work` and its compiler. New Node workspaces use pnpm exclusively. List and dry-run use static configuration and never execute mise, install tools, read secrets, or write files. Dynamic task expressions and parameterized dependencies cannot be previewed. Actual runs also read mise's effective task catalogue. Native mise tasks with their own commands use mise's environment; generated project adapters use One's project environment. @@ -64,7 +62,7 @@ Keep project commands in `package.json` scripts or a Taskfile. For example, addi ```toml [tasks.verify] -depends = ["//apps/web:check", "//services/api:test"] +depends = ["//:web:check", "//:api:test"] [tasks.hello] description = "Print a greeting" @@ -73,11 +71,11 @@ run = "echo hello" Run the aggregate with `one run verify`, or the custom command with `one run hello`. You can also put executable scripts in `.mise/tasks/`, or load a separate task file with `[task_config] includes = ["tasks.toml"]`. -Inspect native configuration with `one mise tasks ls --all --local` and `one mise tasks info //apps/web:build --json`. Custom native tasks can run directly with `mise run hello`; generated project tasks need `one run` to prepare their environment context. +Inspect native configuration with `one mise tasks ls --all --local` and `one mise tasks info //:web:build --json`. Custom native tasks can run directly with `mise run hello`; generated project tasks need `one run` to prepare their environment context. ## Cache -Generated configuration enables mise's experimental features. Known template **build** tasks receive `sources`, `outputs`, and an environment fingerprint. Other tasks stay uncached until they have an explicit cache contract. Changing a template build script or build configuration disables automatic caching; declare the actual inputs and outputs in the project's `mise.toml` to enable it again. Cache storage and artifact restoration belong to mise. +Generated configuration enables mise's experimental features. Known template **build** tasks receive `sources`, `outputs`, and an environment fingerprint. Other tasks stay uncached until they have an explicit cache contract. Changing a template build script or build configuration disables automatic caching; declare the actual inputs and outputs under the project task in the root `mise.toml` to enable it again. Cache storage and artifact restoration belong to mise. ```sh one run build --cache local-only @@ -88,15 +86,18 @@ one run build --concurrency 4 `--cache off --force` ensures execution even if source freshness checks would skip a task. Cache modes also include `read-write` and `write-only`; remote access requires your own mise cache backend configuration. One does not provision a remote service. -To opt a generated task into caching, declare its complete inputs and outputs in the project's `mise.toml`, including One's fingerprint command: +To opt a generated task into caching, declare its complete inputs and outputs under the project task in the root `mise.toml`, including One's fingerprint command: ```toml -[tasks.build] +[tasks."web:build"] +dir = "apps/web" sources = ["src/**/*", "package.json", "tsconfig.json", "../../pnpm-lock.yaml"] outputs = ["dist"] cache = { enabled = true, env = ["NODE_ENV"], command_inputs = ['one __task-input --project "web" --task "build"'] } ``` +Source and output paths are relative to the task’s `dir`. mise also hashes the defining configuration, so editing the root `mise.toml` invalidates caches for tasks defined there. + `outputs = []` is appropriate for a deterministic task that only verifies inputs. Declare every upstream source, external file, configuration value, and compiler input that affects a custom task. Outputs must stay inside the task directory and must not overlap another selected task's outputs. Use `--cache off --force` while checking a new cache declaration. One loads each managed project's environment once per invocation, before cache lookup, into a temporary context. The fingerprint and command consume that same snapshot. Environment values remain out of generated TOML and structured results. Every injected project variable contributes to the fingerprint; authentication credentials used only to fetch variables do not. Directly invoking a generated adapter through `mise run` requires that context, so use `one run` for these tasks. diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md index 6f55332..e73df0b 100644 --- a/apps/docs/content/docs/zh/add.md +++ b/apps/docs/content/docs/zh/add.md @@ -3,7 +3,7 @@ title: one add description: 往工作区里加一个模板化项目。 --- -工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。检查配置直接保存在 `.config/hk.pkl`,用户修改和注释会保留。旧工作区可先通过 `one init hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 +工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。检查配置直接保存在 `.config/hk.pkl`,用户修改和注释会保留。工作区和项目任务统一登记在根 `mise.toml`,子项目继续使用自己的原生命令文件。旧工作区可先通过 `one init hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 `one add` 选择技术栈,生成一个可本地开发的项目并登记到 manifest。CI 和部署默认都保持未配置。 diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index e257e67..e02d97b 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -45,7 +45,7 @@ one create my-app --yes | 环境变量 | 未绑定 | 需要时绑定 Infisical,否则继承 shell 环境 | | 本地开发 | `one dev` | 通过 mise 运行开发任务图 | | Git 检查 | hk | 生成 `.config/hk.pkl` 并安装本地 Git 启动器 | -| 工具环境 | mise | 自动生成根 `mise.toml`;后续 `one add` 自动生成项目 `mise.toml` | +| 工具环境 | mise | 自动生成根 `mise.toml`;后续 `one add` 在同一文件登记项目任务 | | Git 检查 | hk | 创建共享检查配置并安装本地提交钩子;后续 `one add` 增量加入语言检查 | 创建工作区和添加项目时会准备 mise,并自动信任完全由 One 生成的 `mise.toml`,进入新目录无需再单独执行信任命令。已有自定义配置保留 mise 原有的信任检查。本机没有兼容版本时,One 可能下载托管的 mise 程序;项目工具和依赖仍按需安装。信任失败会保留生成文件并给出恢复命令。工具版本与已有 workspace 的启用方式见 [`one init mise`](/zh/docs/login/#mise-工作区工具配置)。 diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index 7b30f2f..5f265d6 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -17,7 +17,7 @@ one dev -p web -- --port 4300 ## 命令与覆盖 -生成的适配任务优先使用 `projects[].dev.command`,未设置时使用项目原生的 `dev` 包脚本或 Taskfile 任务。项目 `mise.toml` 可覆盖任务,根 `mise.toml` 可替换工作区聚合。自定义 mise 命令使用 mise 的环境;生成的适配任务接收 One 冻结的项目环境。 +生成的适配任务优先使用 `projects[].dev.command`,未设置时使用项目原生的 `dev` 包脚本或 Taskfile 任务。在根 `mise.toml` 中可以覆盖 `web:dev` 这样的项目任务,也可以替换工作区聚合。自定义 mise 命令使用 mise 的环境;生成的适配任务接收 One 冻结的项目环境。 ## 依赖准备 diff --git a/apps/docs/content/docs/zh/run.md b/apps/docs/content/docs/zh/run.md index f16ddbe..66ef5c6 100644 --- a/apps/docs/content/docs/zh/run.md +++ b/apps/docs/content/docs/zh/run.md @@ -30,20 +30,18 @@ one run build --dry-run -o json ## 配置与依赖 -One 在工作区和项目根目录的 `mise.toml` 中声明工具版本、项目命令入口、聚合任务和本地 Node 构建依赖。直接编辑这个文件即可添加任务或调整设置。执行任务、添加项目或运行 `one init mise` 时,One 会补充新配置,并更新未被用户修改的生成项;自定义字段和注释保留。根 `dev`、`build`、`check`、`test` 只聚合项目实际存在的任务,`ci` 组合 build、check、test 聚合任务。 +One 只在工作区根目录的 `mise.toml` 中声明工具版本、项目命令入口、聚合任务和本地 Node 构建依赖。子项目保留原生的命令文件,不再生成子项目级 mise 配置。直接编辑这个文件即可添加任务或调整设置。执行任务、添加项目或运行 `one init mise` 时,One 会补充新配置,并更新未被用户修改的生成项;自定义字段和注释保留。根 `dev`、`build`、`check`、`test` 只聚合项目实际存在的任务,`ci` 组合 build、check、test 聚合任务。 文件末尾的 `# one:managed-v1` 注释记录 One 上一次生成的字段。请保留它,以便后续增量更新。如果同一个字段被用户和 One 同时改成不同内容,会报告 `MISE_CONFIG_CONFLICT` 并指出冲突项。仅修改注释、添加自定义任务或调整默认值,不会因为整份文件改变而被拒绝。 ```text workspace/ one.manifest.json - mise.toml # 工具版本、项目目录和聚合任务 + mise.toml # 工具版本、项目任务入口、聚合与缓存配置 .config/hk.pkl # 工作区 Git 检查 apps/web/ - mise.toml # 项目任务与缓存配置 package.json # pnpm 脚本 services/api/ - mise.toml # 项目工具与任务 Taskfile.yml # Go 项目命令 ``` @@ -54,7 +52,7 @@ one init mise --dry-run -o json one init mise ``` -项目任务的规范名称为 `//apps/web:build`。本地 Node 上游存在 build 时,会在下游 build、check、test、typecheck 和 dev 前执行。Go 的模块依赖由 `go.work` 和编译器解析。One 管理的 Node 项目统一使用 pnpm。 +项目任务统一使用根任务名,如 `web:build`(规范名称为 `//:web:build`),并通过 `dir = "apps/web"` 指定执行目录。工具版本统一固定在根目录,Go 默认采用工作区模块要求的最高版本。本地 Node 上游存在 build 时,会在下游 build、check、test、typecheck 和 dev 前执行。Go 的模块依赖由 `go.work` 和编译器解析。One 管理的 Node 项目统一使用 pnpm。 列表和 dry-run 静态读取配置,不启动 mise、不安装工具、不读取密钥、不写文件。动态表达式和带参数的依赖无法静态预览。实际运行还会读取 mise 的有效任务清单。用户直接定义命令的原生 mise 任务使用 mise 环境;One 生成的项目入口使用 One 项目环境。 @@ -64,7 +62,7 @@ one init mise ```toml [tasks.verify] -depends = ["//apps/web:check", "//services/api:test"] +depends = ["//:web:check", "//:api:test"] [tasks.hello] description = "Print a greeting" @@ -73,11 +71,11 @@ run = "echo hello" 使用 `one run verify` 执行聚合,或 `one run hello` 执行自定义命令。也可以把可执行脚本放到 `.mise/tasks/`,或用 `[task_config] includes = ["tasks.toml"]` 加载独立任务文件。 -诊断原生配置可运行 `one mise tasks ls --all --local` 和 `one mise tasks info //apps/web:build --json`。用户自行定义的原生任务可以直接用 `mise run hello`;One 生成的项目任务需要 `one run` 准备环境上下文。 +诊断原生配置可运行 `one mise tasks ls --all --local` 和 `one mise tasks info //:web:build --json`。用户自行定义的原生任务可以直接用 `mise run hello`;One 生成的项目任务需要 `one run` 准备环境上下文。 ## 缓存 -生成配置开启 mise 实验性功能。已知模板的 **build** 任务声明输入、输出和环境指纹;其他任务需要显式声明缓存规则。修改模板构建脚本或构建配置后,自动缓存声明会关闭;在项目 `mise.toml` 中声明实际输入和输出即可重新启用。缓存存储和产物恢复交给 mise。 +生成配置开启 mise 实验性功能。已知模板的 **build** 任务声明输入、输出和环境指纹;其他任务需要显式声明缓存规则。修改模板构建脚本或构建配置后,自动缓存声明会关闭;在根 `mise.toml` 对应的项目任务中声明实际输入和输出即可重新启用。缓存存储和产物恢复交给 mise。 ```sh one run build --cache local-only @@ -88,15 +86,18 @@ one run build --concurrency 4 `--cache off --force` 同时关闭产物缓存和输入新鲜度跳过,确保实际执行。还支持 `read-write` 和 `write-only`;使用远程缓存前需自行配置 mise 缓存后端,One 不创建远程服务。 -在项目的 `mise.toml` 为生成任务开启缓存时,需要完整输入、输出和 One 环境指纹命令: +在根 `mise.toml` 为项目的生成任务开启缓存时,需要完整输入、输出和 One 环境指纹命令: ```toml -[tasks.build] +[tasks."web:build"] +dir = "apps/web" sources = ["src/**/*", "package.json", "tsconfig.json", "../../pnpm-lock.yaml"] outputs = ["dist"] cache = { enabled = true, env = ["NODE_ENV"], command_inputs = ['one __task-input --project "web" --task "build"'] } ``` +输入和输出路径相对于任务的 `dir`。mise 也会将任务所在的配置文件纳入缓存输入,因此修改根 `mise.toml` 会使其中任务的缓存失效。 + 只验证输入且结果确定的任务可以声明 `outputs = []`。自定义任务应包含所有上游源码、外部文件、配置值和编译器输入。输出必须位于任务目录内,且不能与同次执行其他任务的输出重叠。新配置先用 `--cache off --force` 验证。 每次运行中,One 在缓存查找前读取一次每个托管项目的环境,保存为临时上下文。指纹计算和执行使用同一份快照。环境值不会写入生成 TOML 或结构化结果。所有注入项目的变量都参与指纹;仅用于读取变量的账号凭据不参与。生成入口必须通过 `one run` 获得上下文,不能直接用 `mise run` 绕过。 diff --git a/mise.toml b/mise.toml index bc4c70d..7614311 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ min_version = "2026.9.7" +monorepo_root = true [settings] experimental = true @@ -139,3 +140,359 @@ run = "pnpm --filter one-cli-docs dev" [tasks."docs:build"] depends = ["install-deps"] run = "pnpm --filter one-cli-docs build" + +[monorepo] +config_roots = [] +lockfile = false + + + +# Project task prerequisites and overrides. +# 子项目任务的前置步骤和覆盖统一放在根目录。 + +[tasks."cli:build"] +depends = ["//:sync-bundled", "//:sync-web"] + + +description = 'Build One after mise has prepared embedded resources' +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'build' --" +run_windows = 'one __task --project "cli" --task "build" --' +[tasks."cli:build-local"] +depends = ["//:sync-bundled", "//:sync-web"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'build-local' --" +run_windows = 'one __task --project "cli" --task "build-local" --' +[tasks."cli:build-all"] +depends = ["//:sync-bundled", "//:sync-web"] + + +description = 'Cross compile the supported release targets' +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'build-all' --" +run_windows = 'one __task --project "cli" --task "build-all" --' +[tasks."cli:check"] +depends = ["//:sync-bundled", "//:sync-web"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'check' --" +run_windows = 'one __task --project "cli" --task "check" --' +[tasks."cli:dev"] +depends = ["//:sync-bundled", "//:sync-web"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'dev' --" +run_windows = 'one __task --project "cli" --task "dev" --' +[tasks."cli:verify-docs"] +depends = ["//:sync-bundled", "//:sync-web"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'verify-docs' --" +run_windows = 'one __task --project "cli" --task "verify-docs" --' +[tasks."cli:gen-error-codes"] +depends = ["//:sync-bundled", "//:sync-web"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'gen-error-codes' --" +run_windows = 'one __task --project "cli" --task "gen-error-codes" --' +[tasks."cli:test"] +depends = ["//:build"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'test' --" +run_windows = 'one __task --project "cli" --task "test" --' +[tasks."cli:test:race"] +depends = ["//:build"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'test:race' --" +run_windows = 'one __task --project "cli" --task "test:race" --' +[tasks."cli:test:snapshot"] +depends = ["//:build"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'test:snapshot' --" +run_windows = 'one __task --project "cli" --task "test:snapshot" --' +[tasks."cli:sync-web"] +depends = ["//:build-web"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'sync-web' --" +run_windows = 'one __task --project "cli" --task "sync-web" --' +[tasks."cli:install-launcher"] +depends = ["//:build-local"] + + +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'install-launcher' --" +run_windows = 'one __task --project "cli" --task "install-launcher" --' +[tasks."dashboard:dev".env] +VITE_DEV_API_TARGET = "http://127.0.0.1:5174" +VITE_DEV_DATA_MODE = "fixture-live-session" + +[tasks.'cli:build'.cache] +enabled = false + +[tasks.'cli:build-all'.cache] +enabled = false + +[tasks.'cli:build-local'.cache] +enabled = false + +[tasks.'cli:check'.cache] +enabled = false + +[tasks.'cli:dev'.cache] +enabled = false + +[tasks.'cli:fmt'.cache] +enabled = false + +[tasks.'cli:fmt'] +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'fmt' --" +run_windows = 'one __task --project "cli" --task "fmt" --' + +[tasks.'cli:gen-error-codes'.cache] +enabled = false + +[tasks.'cli:install-launcher'.cache] +enabled = false + +[tasks.'cli:sync-bundled'.cache] +enabled = false + +[tasks.'cli:sync-bundled'] +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'sync-bundled' --" +run_windows = 'one __task --project "cli" --task "sync-bundled" --' + +[tasks.'cli:sync-web'.cache] +enabled = false + +[tasks.'cli:test'.cache] +enabled = false + +[tasks.'cli:test:race'.cache] +enabled = false + +[tasks.'cli:test:snapshot'.cache] +enabled = false + +[tasks.'cli:tidy'.cache] +enabled = false + +[tasks.'cli:tidy'] +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'tidy' --" +run_windows = 'one __task --project "cli" --task "tidy" --' + +[tasks.'cli:verify-docs'.cache] +enabled = false + +[tasks.'cli:verify-node-workspace'.cache] +enabled = false + +[tasks.'cli:verify-node-workspace'] +dir = 'packages/cli' +raw_args = true +run = "one __task --project 'cli' --task 'verify-node-workspace' --" +run_windows = 'one __task --project "cli" --task "verify-node-workspace" --' + +[tasks.'dashboard:build'.cache] +enabled = false + +[tasks.'dashboard:build'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'build' --" +run_windows = 'one __task --project "dashboard" --task "build" --' + +[tasks.'dashboard:check'.cache] +enabled = false + +[tasks.'dashboard:check'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'check' --" +run_windows = 'one __task --project "dashboard" --task "check" --' + +[tasks.'dashboard:check:fix'.cache] +enabled = false + +[tasks.'dashboard:check:fix'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'check:fix' --" +run_windows = 'one __task --project "dashboard" --task "check:fix" --' + +[tasks.'dashboard:dev'.cache] +enabled = false + +[tasks.'dashboard:dev'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'dev' --" +run_windows = 'one __task --project "dashboard" --task "dev" --' + +[tasks.'dashboard:format'.cache] +enabled = false + +[tasks.'dashboard:format'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'format' --" +run_windows = 'one __task --project "dashboard" --task "format" --' + +[tasks.'dashboard:format:fix'.cache] +enabled = false + +[tasks.'dashboard:format:fix'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'format:fix' --" +run_windows = 'one __task --project "dashboard" --task "format:fix" --' + +[tasks.'dashboard:lint'.cache] +enabled = false + +[tasks.'dashboard:lint'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'lint' --" +run_windows = 'one __task --project "dashboard" --task "lint" --' + +[tasks.'dashboard:lint:fix'.cache] +enabled = false + +[tasks.'dashboard:lint:fix'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'lint:fix' --" +run_windows = 'one __task --project "dashboard" --task "lint:fix" --' + +[tasks.'dashboard:preview'.cache] +enabled = false + +[tasks.'dashboard:preview'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'preview' --" +run_windows = 'one __task --project "dashboard" --task "preview" --' + +[tasks.'dashboard:test'.cache] +enabled = false + +[tasks.'dashboard:test'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'test' --" +run_windows = 'one __task --project "dashboard" --task "test" --' + +[tasks.'dashboard:test:watch'.cache] +enabled = false + +[tasks.'dashboard:test:watch'] +dir = 'apps/dashboard' +raw_args = true +run = "one __task --project 'dashboard' --task 'test:watch' --" +run_windows = 'one __task --project "dashboard" --task "test:watch" --' + +[tasks.'docs:postinstall'.cache] +enabled = false + +[tasks.'docs:postinstall'] +dir = 'apps/docs' +raw_args = true +run = "one __task --project 'docs' --task 'postinstall' --" +run_windows = 'one __task --project "docs" --task "postinstall" --' + +[tasks.'docs:start'.cache] +enabled = false + +[tasks.'docs:start'] +dir = 'apps/docs' +raw_args = true +run = "one __task --project 'docs' --task 'start' --" +run_windows = 'one __task --project "docs" --task "start" --' + +[tasks.'kernel:build'.cache] +enabled = false + +[tasks.'kernel:build'] +dir = 'packages/kernel' +raw_args = true +run = "one __task --project 'kernel' --task 'build' --" +run_windows = 'one __task --project "kernel" --task "build" --' + +[tasks.'kernel:check'.cache] +enabled = false + +[tasks.'kernel:check'] +dir = 'packages/kernel' +raw_args = true +run = "one __task --project 'kernel' --task 'check' --" +run_windows = 'one __task --project "kernel" --task "check" --' + +[tasks.'kernel:fmt'.cache] +enabled = false + +[tasks.'kernel:fmt'] +dir = 'packages/kernel' +raw_args = true +run = "one __task --project 'kernel' --task 'fmt' --" +run_windows = 'one __task --project "kernel" --task "fmt" --' + +[tasks.'kernel:test'.cache] +enabled = false + +[tasks.'kernel:test'] +dir = 'packages/kernel' +raw_args = true +run = "one __task --project 'kernel' --task 'test' --" +run_windows = 'one __task --project "kernel" --task "test" --' + +[tasks.'kernel:test:race'.cache] +enabled = false + +[tasks.'kernel:test:race'] +dir = 'packages/kernel' +raw_args = true +run = "one __task --project 'kernel' --task 'test:race' --" +run_windows = 'one __task --project "kernel" --task "test:race" --' + +[tasks.'kernel:tidy'.cache] +enabled = false + +[tasks.'kernel:tidy'] +dir = 'packages/kernel' +raw_args = true +run = "one __task --project 'kernel' --task 'tidy' --" +run_windows = 'one __task --project "kernel" --task "tidy" --' + +# one:managed-v1 {"[\"monorepo\",\"config_roots\"]":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","[\"monorepo\",\"lockfile\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"monorepo_root\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:build\",\"description\"]":"cde82f02859fcc6f4226e9e2b4b98fcd1bb53d68fd66378d6b12ed9587dd9041","[\"tasks\",\"cli:build\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build\",\"run\"]":"f8dc1af8194670578445ad5b72b4a29f87043c0f4b5daa727d12a53d5c80e5f8","[\"tasks\",\"cli:build\",\"run_windows\"]":"cb44c07c5b20b81e2a523ef003190dbb8ece573d23af88ddda5ad4556866ea95","[\"tasks\",\"cli:build-all\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:build-all\",\"description\"]":"5a2810b1f882281debfbcb8fc7ad8840986e6b7c9d2332a5a77f3ad104387f51","[\"tasks\",\"cli:build-all\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build-all\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build-all\",\"run\"]":"05b9e042aa3d2aa5c4ef572a30c06a811c66a1f2134bb3065d09866e5921fff8","[\"tasks\",\"cli:build-all\",\"run_windows\"]":"4933b54586db6c8a3f81e809cd785a48eedd08a2d3cc757c68ffd48e074972b4","[\"tasks\",\"cli:build-local\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:build-local\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build-local\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build-local\",\"run\"]":"eefebcccd1b427336b5aa081fcd2320553a78ebc586cb9700f6cb3dd73c6d54c","[\"tasks\",\"cli:build-local\",\"run_windows\"]":"2f925ee186b9bb6bd6cf929d4ec08824e1a53c8a982714a06b21b247af51ffee","[\"tasks\",\"cli:check\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:check\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:check\",\"run\"]":"2f97f7da613c554b186404721b62a98656af313da2d76849d5dee90527271f10","[\"tasks\",\"cli:check\",\"run_windows\"]":"83d5065b3788415aea9d269a7b624ac253af8d12e34f5eadfe19b0bd28055144","[\"tasks\",\"cli:dev\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:dev\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:dev\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:dev\",\"run\"]":"f43ed1f4ec76c32db7e044ebb168aacc855da3aa212ba62d3498d38c24edea94","[\"tasks\",\"cli:dev\",\"run_windows\"]":"99bc86a4c6e669f4bdc6b1c6de61f353fd0b6564658992f0ae7da5a20db7c414","[\"tasks\",\"cli:fmt\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:fmt\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:fmt\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:fmt\",\"run\"]":"a6767c0ede2aeec6c925422868089b93964010babfe55f84c1620adee9c5a930","[\"tasks\",\"cli:fmt\",\"run_windows\"]":"661f68d86ad9ecd1bcba26f42e44b4728401233e03bc286f1114497fddd71652","[\"tasks\",\"cli:gen-error-codes\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:gen-error-codes\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:gen-error-codes\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:gen-error-codes\",\"run\"]":"7eeddc5348043dc50d388bae274c6a6edf46072526b9db5f8f5c3c6c6d2ab2e5","[\"tasks\",\"cli:gen-error-codes\",\"run_windows\"]":"5443e5946db5a8ef572cff9c3f016a1fcc4c00e7377752444d8f9b800f3d0c65","[\"tasks\",\"cli:install-launcher\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:install-launcher\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:install-launcher\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:install-launcher\",\"run\"]":"dfc4302cd315c1aa2641a2871eff0317f112244cff574c395202727df265626d","[\"tasks\",\"cli:install-launcher\",\"run_windows\"]":"adf2d9f9a5748bebbbfe2ae956caa458168c954b9e9f30e060e38c9c50398c3c","[\"tasks\",\"cli:sync-bundled\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:sync-bundled\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:sync-bundled\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:sync-bundled\",\"run\"]":"34533162d54579cc92e5955d69cd7cc71158bcd6f3e6e146aaab8cda0d3b65f3","[\"tasks\",\"cli:sync-bundled\",\"run_windows\"]":"518bb787c223aa5ca1a4f7bc63c8b659f66b7a44ace52c58acb01575fef70166","[\"tasks\",\"cli:sync-web\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:sync-web\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:sync-web\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:sync-web\",\"run\"]":"0aaf990c8d3b20a5500493b693837d0632fc95f254a96393cd1fc0915ac0d283","[\"tasks\",\"cli:sync-web\",\"run_windows\"]":"b852c24f604877fb89af647bb1bc6bfbb42727354ce7152e58c8caf67f320b16","[\"tasks\",\"cli:test\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:test\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:test\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:test\",\"run\"]":"13495b29aed948538d6a76a8d38a09ee8571070b6e6882293f057f3d6758b859","[\"tasks\",\"cli:test\",\"run_windows\"]":"b2cca2284a18d28045f97beeb715eb01fa14446908b0b52014a9d625309a57c7","[\"tasks\",\"cli:test:race\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:test:race\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:test:race\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:test:race\",\"run\"]":"36c74f4e83acc6c54ad35f0e55275b240814e743b558f097ba9d6bc318c5a6de","[\"tasks\",\"cli:test:race\",\"run_windows\"]":"9953378f30dd8bb82759c56aaaa357cc56c365b3167de338894b3fbd746d5ffa","[\"tasks\",\"cli:test:snapshot\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:test:snapshot\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:test:snapshot\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:test:snapshot\",\"run\"]":"062df0e8c026c104096b44b20092ab5607b5c4ac6f8f412037710940bd7ba558","[\"tasks\",\"cli:test:snapshot\",\"run_windows\"]":"08f24291d587b91d5a6f8816da1f56737af90fa4c932108234f14c5efb536793","[\"tasks\",\"cli:tidy\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:tidy\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:tidy\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:tidy\",\"run\"]":"d87b1e67170246c82a5e675b22f2c8509a7a53c0011ea3514784a82bd55f16cf","[\"tasks\",\"cli:tidy\",\"run_windows\"]":"9d39ec7b72c6b1b53bc391099066bd490510d33234306529a47e7cf4a7d8c1ba","[\"tasks\",\"cli:verify-docs\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:verify-docs\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:verify-docs\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:verify-docs\",\"run\"]":"204930ed136bded8edf7c0f86f5d3b47dbdfc20df36593ed1cb1db7cf3e80c15","[\"tasks\",\"cli:verify-docs\",\"run_windows\"]":"f9e2379cdd9f4aaadafce0ce08d69054f617ef9e907d420b3b399e27aee107e4","[\"tasks\",\"cli:verify-node-workspace\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"cli:verify-node-workspace\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:verify-node-workspace\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:verify-node-workspace\",\"run\"]":"d7ebb1b535ca1895e3611e7fd1686c8f9f81dbc4aa4ebb3f2e4493b381600c1c","[\"tasks\",\"cli:verify-node-workspace\",\"run_windows\"]":"b18ad034284cf4edcbd708828ba8a9369f11cacf0121116ee51821194088eeb1","[\"tasks\",\"dashboard:build\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:build\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:build\",\"run\"]":"19270527392ab087669efc9a188f66bdff0e30ba48396046adbf43ee9e924230","[\"tasks\",\"dashboard:build\",\"run_windows\"]":"1b1c9681aec1626b1e561cf9e84333898e8f0d500e34da8869765c13699e76a8","[\"tasks\",\"dashboard:check\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:check\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:check\",\"run\"]":"eddca49b1e55c80877318d5f45feb745443941fdbb935494b10ea34a8417c46f","[\"tasks\",\"dashboard:check\",\"run_windows\"]":"3fa8f827a86c0fc43e85d4bce904bd8724026892512dcf3c145a1334062d6a4d","[\"tasks\",\"dashboard:check:fix\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:check:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:check:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:check:fix\",\"run\"]":"53485fec4fe4f95f9ab28a11dcba9fd08f7f8042beae7524e0cd9a038f33877f","[\"tasks\",\"dashboard:check:fix\",\"run_windows\"]":"e1268c94f03feb74a28ecd4d8ad883476c161a7092a81a2ed4755a9d591e1453","[\"tasks\",\"dashboard:dev\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:dev\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:dev\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:dev\",\"run\"]":"2256a9aad23d430283d64f1c1ce961e321d3a4fc8efbbfa6ef942568cd0dade7","[\"tasks\",\"dashboard:dev\",\"run_windows\"]":"156321ca3de1f649e4947ac4bf3cf06fc236ac373b591b95f6f6b19673d16e19","[\"tasks\",\"dashboard:format\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:format\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:format\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:format\",\"run\"]":"1f853569bb09f12e0293686c3a7b72cb775aa0abccf7bb9f3c36435752ebdb52","[\"tasks\",\"dashboard:format\",\"run_windows\"]":"18a973410203223dfea39532bc040e965a3b01761d846e6ce7c7e93c37291ac6","[\"tasks\",\"dashboard:format:fix\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:format:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:format:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:format:fix\",\"run\"]":"6b163167fbe8e3370957d4a6c50cd6944a21f75a0aeed913e78e42f71e7fc3ce","[\"tasks\",\"dashboard:format:fix\",\"run_windows\"]":"3cfb83d4dc4efc21c3305d8582fcce51bc47a57b0304d9a370e12abe25641bd1","[\"tasks\",\"dashboard:lint\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:lint\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:lint\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:lint\",\"run\"]":"bfac593b4497f40acf46b50c6f74a2a37256eaf9ccd14b49946db8e7e531888b","[\"tasks\",\"dashboard:lint\",\"run_windows\"]":"45be4fd25f8d2250ade2a48ef2753cd4f005a278c9a3e524d39dcb3b5d28cd07","[\"tasks\",\"dashboard:lint:fix\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:lint:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:lint:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:lint:fix\",\"run\"]":"f311b310432f7187715f0cd29eb29708f0c604e9f70eb73484d7f73557b1176c","[\"tasks\",\"dashboard:lint:fix\",\"run_windows\"]":"298e87c9f1d365ebf7281a15dcfa0851b18fdf0560ef3f84b7a42a2bb18e6c26","[\"tasks\",\"dashboard:preview\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:preview\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:preview\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:preview\",\"run\"]":"4be49039a28802a7470cb057f17cb7ec83618207229c690f275e0a3000b0a8de","[\"tasks\",\"dashboard:preview\",\"run_windows\"]":"1dca4dfd38848faf3f34bfb4dfb0ab446f807b88d9c0d41f743d969720b8309c","[\"tasks\",\"dashboard:test\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:test\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:test\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:test\",\"run\"]":"ba6aff56dfe3acb93402a98a979121a61e455d61a8e13bb356b01e227c00ed73","[\"tasks\",\"dashboard:test\",\"run_windows\"]":"ffc1eeba5a08319f7e36eeef9d32755469a6c4ed45b4b7706d277ec43ae6b138","[\"tasks\",\"dashboard:test:watch\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"dashboard:test:watch\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:test:watch\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:test:watch\",\"run\"]":"94fdc767b5f4e5503f26f6b1575993aed60353d71c1cfea35bd07f51da011d55","[\"tasks\",\"dashboard:test:watch\",\"run_windows\"]":"9f1a355e3c68fb3ed1c59c283889cbfa2be7ef3bad53d3ea4cc328a9d8aa4b23","[\"tasks\",\"docs:postinstall\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"docs:postinstall\",\"dir\"]":"2e3e8b4189b911510e95ceea3224aff2883d9b522446768f70131a68cd15f6b0","[\"tasks\",\"docs:postinstall\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"docs:postinstall\",\"run\"]":"dddbe535bf70b931eaf91c818a94843f26de227d482c4bfb7b2d90e61a3d3f33","[\"tasks\",\"docs:postinstall\",\"run_windows\"]":"e2d6f18de034617affea54ad38f127fb0f45b89323d65b6a380a224e42d5fdc4","[\"tasks\",\"docs:start\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"docs:start\",\"dir\"]":"2e3e8b4189b911510e95ceea3224aff2883d9b522446768f70131a68cd15f6b0","[\"tasks\",\"docs:start\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"docs:start\",\"run\"]":"2adf02eeee8d6bbda94c3577dcf447c1af6fee68d61aa6f986846cf664267513","[\"tasks\",\"docs:start\",\"run_windows\"]":"4ff0fb347ca970aadef38a3246cc9e7b8d9d67d4c6ecd12adbeeae8d956a1ce9","[\"tasks\",\"kernel:build\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"kernel:build\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:build\",\"run\"]":"a929c826450ff4832a5ada412d79952799d1bcd330afc6f894bfb99633bda944","[\"tasks\",\"kernel:build\",\"run_windows\"]":"35db2155b617e49e16819277ca7d632886760218e1f1a5e6f7455ac95e324b25","[\"tasks\",\"kernel:check\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"kernel:check\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:check\",\"run\"]":"dc8b3f153f487820ca7349d0289b04aa559b614fc98be822798da2b01a5393f9","[\"tasks\",\"kernel:check\",\"run_windows\"]":"4505eef822309742ad07cb3b252be2ee6522b77e74533b306c073ad3c6de680a","[\"tasks\",\"kernel:fmt\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"kernel:fmt\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:fmt\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:fmt\",\"run\"]":"32443daf185d3cdfb90cb4d2ef5ab7e4f25a4b578122938504a1e61b1d0f6756","[\"tasks\",\"kernel:fmt\",\"run_windows\"]":"c6089b2ec96c0afcec5b858303d7f1295850c92184c8c2852c6ebede3c7f18e7","[\"tasks\",\"kernel:test\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"kernel:test\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:test\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:test\",\"run\"]":"79bd8a2d80bd4821129f9657b3f340c36beb08e04e1dfc8556aa59e43826bc4a","[\"tasks\",\"kernel:test\",\"run_windows\"]":"9542fdcd57d5635e75306b4e4375b61c0d31c4d1e79a88bc3d524213bc73c19f","[\"tasks\",\"kernel:test:race\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"kernel:test:race\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:test:race\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:test:race\",\"run\"]":"8c7e1f27108e75a87da51a8943856e26c355a69cfe6387ef2ace0ac447f2e85f","[\"tasks\",\"kernel:test:race\",\"run_windows\"]":"9d248c475d362bcd35f3af10dbc5b9f0eac43f26a2e1798117d89d096248bbcc","[\"tasks\",\"kernel:tidy\",\"cache\",\"enabled\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"tasks\",\"kernel:tidy\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:tidy\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:tidy\",\"run\"]":"54c6127f861f482fdcef87aa45cdc80c9ab8696766cf7ee0fb267fad8b2d3dff","[\"tasks\",\"kernel:tidy\",\"run_windows\"]":"96a6add018adaa875bd6ac6034acc56d5ac65f1fceb0145819cacefe7925f523"} diff --git a/one.manifest.json b/one.manifest.json new file mode 100644 index 0000000..800b233 --- /dev/null +++ b/one.manifest.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "workspace": { + "id": "one-cli", + "name": "One CLI" + }, + "environments": { + "names": [ + "dev", + "preview", + "prod" + ], + "default": "dev" + }, + "projects": [ + { + "name": "dashboard", + "relativeDir": "apps/dashboard", + "templateId": "react-spa", + "toolchain": "node", + "packageManager": "pnpm", + "dev": { + "url": "http://localhost:5173" + } + }, + { + "name": "docs", + "relativeDir": "apps/docs", + "templateId": "nextjs-app", + "toolchain": "node", + "packageManager": "pnpm", + "dev": { + "url": "http://localhost:3000" + } + }, + { + "name": "cli", + "relativeDir": "packages/cli", + "templateId": "custom", + "toolchain": "go", + "dev": { + "url": "http://127.0.0.1:5174" + } + }, + { + "name": "kernel", + "relativeDir": "packages/kernel", + "templateId": "custom", + "toolchain": "go" + } + ] +} diff --git a/packages/cli/internal/modules/creation/languages_test.go b/packages/cli/internal/modules/creation/languages_test.go index 0512252..dda2560 100644 --- a/packages/cli/internal/modules/creation/languages_test.go +++ b/packages/cli/internal/modules/creation/languages_test.go @@ -95,7 +95,7 @@ func TestFirstProjectInitializesLanguageWorkspaceInEitherOrder(t *testing.T) { } } rootMise, _ := os.ReadFile(filepath.Join(root, workspace.MiseConfigFilename)) - if strings.Contains(string(rootMise), "go =") || !strings.Contains(string(rootMise), "pnpm =") { + if !strings.Contains(string(rootMise), "go = '1.27.0'") || !strings.Contains(string(rootMise), "pnpm =") { t.Fatalf("wrong root tools: %s", rootMise) } hk, err := os.ReadFile(filepath.Join(root, workspace.HooksConfigFilename)) @@ -121,9 +121,10 @@ func TestFirstProjectInitializesLanguageWorkspaceInEitherOrder(t *testing.T) { t.Errorf("obsolete workspace file %s", obsolete) } } - goMise, _ := os.ReadFile(filepath.Join(root, "services/api", workspace.MiseConfigFilename)) - if !strings.Contains(string(goMise), "go = '1.27.0'") { - t.Fatalf("Go tool not configured: %s", goMise) + for _, project := range m.Projects { + if _, err := os.Stat(filepath.Join(root, project.RelativeDir, workspace.MiseConfigFilename)); !os.IsNotExist(err) { + t.Fatalf("unexpected project mise configuration for %s: %v", project.Name, err) + } } }) } diff --git a/packages/cli/internal/modules/miseconfig/cache_test.go b/packages/cli/internal/modules/miseconfig/cache_test.go index 85f0350..f85d089 100644 --- a/packages/cli/internal/modules/miseconfig/cache_test.go +++ b/packages/cli/internal/modules/miseconfig/cache_test.go @@ -31,7 +31,7 @@ func TestGoCacheIncludesExternalWorkspaceSourcesAndRejectsChangedBuild(t *testin } readBuild := func() Task { t.Helper() - raw, err := os.ReadFile(filepath.Join(root, "services/api", Filename)) + raw, err := os.ReadFile(filepath.Join(root, Filename)) if err != nil { t.Fatal(err) } @@ -39,7 +39,7 @@ func TestGoCacheIncludesExternalWorkspaceSourcesAndRejectsChangedBuild(t *testin if err = toml.Unmarshal(raw, &config); err != nil { t.Fatal(err) } - return config.Tasks["build"] + return config.Tasks["api:build"] } build := readBuild() if build.Cache == nil || !build.Cache.Enabled { @@ -81,13 +81,13 @@ func TestNodeCacheRequiresKnownOutputConfiguration(t *testing.T) { if err = plan.Apply(context.Background()); err != nil { t.Fatal(err) } - raw, _ := os.ReadFile(filepath.Join(root, "apps/web", Filename)) + raw, _ := os.ReadFile(filepath.Join(root, Filename)) var config config if err = toml.Unmarshal(raw, &config); err != nil { t.Fatal(err) } - if config.Tasks["build"].Cache.Enabled != want { - t.Fatalf("cache enabled = %v, want %v", config.Tasks["build"].Cache.Enabled, want) + if config.Tasks["web:build"].Cache.Enabled != want { + t.Fatalf("cache enabled = %v, want %v", config.Tasks["web:build"].Cache.Enabled, want) } } check(true) diff --git a/packages/cli/internal/modules/miseconfig/config.go b/packages/cli/internal/modules/miseconfig/config.go index af66b80..65bf194 100644 --- a/packages/cli/internal/modules/miseconfig/config.go +++ b/packages/cli/internal/modules/miseconfig/config.go @@ -1,4 +1,4 @@ -// Package miseconfig maintains workspace and project mise.toml defaults. +// Package miseconfig maintains the workspace-root mise.toml. package miseconfig import ( @@ -74,6 +74,7 @@ type monorepo struct { // Task is the generated mise declaration. User overrides remain in mise.toml. type Task struct { + Directory string `toml:"dir,omitempty" json:"dir,omitempty"` Description string `toml:"description,omitempty" json:"description,omitempty"` Run string `toml:"run,omitempty" json:"run,omitempty"` RunWindows string `toml:"run_windows,omitempty" json:"run_windows,omitempty"` @@ -227,7 +228,6 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, if rel == "." || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, "../") { return nil, conflict(rel, i18n.T("config.project_path")) } - rootConfig.Monorepo.ConfigRoots = append(rootConfig.Monorepo.ConfigRoots, rel) pc := config{MinVersion: runtimeport.MinimumMiseVersion, Tasks: map[string]Task{}, Tools: map[string]string{}} nativeProject := workspace.Project{Name: project.Name, RelativeDir: rel, TargetDir: filepath.Join(root, rel), Toolchain: project.Toolchain, PackageManager: project.PackageManager, TemplateID: project.TemplateID} operations, err := workspace.DiscoverTasks(root, nativeProject, p.readOptional) @@ -264,14 +264,16 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, if goWorkspace.Version != "" && compareVersions(version, goWorkspace.Version) < 0 { version = goWorkspace.Version } - pc.Tools["go"] = version + if current := rootConfig.Tools["go"]; current == "" || compareVersions(version, current) > 0 { + rootConfig.Tools["go"] = version + } } - pc.Tools["task"] = "3.51.1" + rootConfig.Tools["task"] = "3.51.1" } for _, op := range operations { // Stable command text keeps per-run context paths out of cache keys. args := " --project " + shellQuote(project.Name) + " --task " + shellQuote(op.Name) - task := Task{Description: op.Description, Run: "one __task" + args + " --", RawArgs: true, Interactive: op.Interactive, Cache: &Cache{Enabled: false}} + task := Task{Directory: rel, Description: op.Description, Run: "one __task" + args + " --", RawArgs: true, Interactive: op.Interactive, Cache: &Cache{Enabled: false}} task.RunWindows = "one __task --project " + windowsQuote(project.Name) + " --task " + windowsQuote(op.Name) + " --" if !op.Interactive { p.configureCache(root, nativeProject, op, &task) @@ -279,7 +281,7 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, pc.Tasks[op.Name] = task if op.Name == "build" || op.Name == "check" || op.Name == "test" || op.Name == "dev" { aggregate := rootConfig.Tasks[op.Name] - aggregate.Depends = append(aggregate.Depends, "//"+rel+":"+op.Name) + aggregate.Depends = append(aggregate.Depends, "//:"+project.Name+":"+op.Name) rootConfig.Tasks[op.Name] = aggregate } } @@ -305,7 +307,7 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, upstream := byName[dep] if name == "build" || name == "dev" || name == "check" || name == "test" || name == "typecheck" { if _, ok := configs[dep].Tasks["build"]; ok { - task.Depends = append(task.Depends, "//"+upstream.RelativeDir+":build") + task.Depends = append(task.Depends, "//:"+dep+":build") } } if task.Cache != nil && task.Cache.Enabled { @@ -320,10 +322,7 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, } task.Sources = append(task.Sources, inputs...) } - pc.Tasks[name] = task - } - if err := p.add(project.RelativeDir+"/"+Filename, pc); err != nil { - return nil, err + rootConfig.Tasks[project.Name+":"+name] = task } } var ci Task @@ -336,7 +335,6 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, rootConfig.Tasks["ci"] = ci } - sort.Strings(rootConfig.Monorepo.ConfigRoots) if err := p.add(Filename, rootConfig); err != nil { return nil, err } diff --git a/packages/cli/internal/modules/miseconfig/config_test.go b/packages/cli/internal/modules/miseconfig/config_test.go index 3ebf902..557b6a1 100644 --- a/packages/cli/internal/modules/miseconfig/config_test.go +++ b/packages/cli/internal/modules/miseconfig/config_test.go @@ -45,7 +45,7 @@ func TestConfigurationGenerationIsAdditiveAndIdempotent(t *testing.T) { if err != nil { t.Fatal(err) } - if len(p.Changes) != 3 || !p.DryRun { + if len(p.Changes) != 1 || !p.DryRun { t.Fatalf("unexpected plan: %+v", p) } if actual, _ := os.ReadFile(filepath.Join(root, Filename)); string(actual) != string(userBefore) { @@ -62,13 +62,20 @@ func TestConfigurationGenerationIsAdditiveAndIdempotent(t *testing.T) { if rootConfig.Tools["node"] != "25.4.0" || rootConfig.Tools["pnpm"] != "10.14.0" { t.Fatalf("tools: %+v", rootConfig.Tools) } - web, _ := os.ReadFile(filepath.Join(root, "apps/web", Filename)) - if !strings.Contains(string(web), "[tasks.build]") || strings.Contains(string(web), "[tasks.lint]") || strings.Contains(string(web), "vite") { - t.Fatalf("tasks do not reference the source commands: %s", web) + if !rootConfig.MonorepoRoot || len(rootConfig.Monorepo.ConfigRoots) != 0 { + t.Fatalf("expected root-only scheduling: %+v", rootConfig.Monorepo) } - if !strings.Contains(string(web), "one __task --project 'web' --task 'dev'") { + if rootConfig.Tasks["web:build"].Directory != "apps/web" || strings.Contains(string(rootRaw), "vite") { + t.Fatalf("tasks do not reference the source commands: %s", rootRaw) + } + if !strings.Contains(rootConfig.Tasks["web:dev"].Run, "one __task --project 'web' --task 'dev'") { t.Fatal("missing terminal execution leaf") } + for _, dir := range []string{"apps/web", "services/api"} { + if _, err := os.Stat(filepath.Join(root, dir, Filename)); !os.IsNotExist(err) { + t.Fatalf("generated a project configuration in %s: %v", dir, err) + } + } second, err := Build(root, Options{}) if err != nil { t.Fatal(err) @@ -94,7 +101,7 @@ func TestGoVersionHonorsMinimumAndToolchain(t *testing.T) { if err := p.Apply(context.Background()); err != nil { t.Fatal(err) } - raw, err := os.ReadFile(filepath.Join(root, "services/api", Filename)) + raw, err := os.ReadFile(filepath.Join(root, Filename)) if err != nil || !strings.Contains(string(raw), "1.25.6") { t.Fatalf("toolchain ignored: %v %s", err, raw) } @@ -103,7 +110,7 @@ func TestGoVersionHonorsMinimumAndToolchain(t *testing.T) { } } -func TestGoWorkspaceVersionAppliesToMembersWithoutAddingGoToNode(t *testing.T) { +func TestGoWorkspaceVersionIsPinnedAtRoot(t *testing.T) { root := fixture(t) writeFixture(t, filepath.Join(root, "go.work"), "go 1.27.0\nuse ./services/api\n") p, err := Build(root, Options{}) @@ -113,16 +120,10 @@ func TestGoWorkspaceVersionAppliesToMembersWithoutAddingGoToNode(t *testing.T) { if err := p.Apply(context.Background()); err != nil { t.Fatal(err) } - goConfig, _ := os.ReadFile(filepath.Join(root, "services/api", Filename)) + goConfig, _ := os.ReadFile(filepath.Join(root, Filename)) if !strings.Contains(string(goConfig), "go = '1.27.0'") { t.Fatalf("workspace version ignored: %s", goConfig) } - for _, rel := range []string{Filename, "apps/web/" + Filename} { - b, _ := os.ReadFile(filepath.Join(root, rel)) - if strings.Contains(string(b), "go =") { - t.Fatalf("Node inherited Go: %s", b) - } - } if _, err := Build(root, Options{GoVersion: "1.26.0"}); err == nil { t.Fatal("accepted override below workspace minimum") } @@ -206,7 +207,7 @@ func TestGeneratorAcceptsEmptyFileAndRejectsSymlink(t *testing.T) { }) } -func TestFailedWriteRestoresAlreadyWrittenFiles(t *testing.T) { +func TestFailedWritePreservesRootConfiguration(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission failure fixture is Unix-specific") } @@ -215,7 +216,8 @@ func TestFailedWriteRestoresAlreadyWrittenFiles(t *testing.T) { if err != nil { t.Fatal(err) } - blocked := filepath.Join(root, "services/api") + blocked := root + before, _ := os.ReadFile(filepath.Join(root, Filename)) if err := os.MkdirAll(blocked, 0o755); err != nil { t.Fatal(err) } @@ -230,8 +232,9 @@ func TestFailedWriteRestoresAlreadyWrittenFiles(t *testing.T) { if err := p.Apply(context.Background()); err == nil { t.Fatal("expected write failure") } - if _, err := os.Stat(filepath.Join(root, "apps/web", Filename)); !os.IsNotExist(err) { - t.Fatal("partial configuration was not rolled back") + after, _ := os.ReadFile(filepath.Join(root, Filename)) + if string(after) != string(before) { + t.Fatal("failed write changed root configuration") } } @@ -247,14 +250,14 @@ func TestGoWithoutTaskfileDoesNotGenerateBuildFallback(t *testing.T) { if err := plan.Apply(context.Background()); err != nil { t.Fatal(err) } - raw, err := os.ReadFile(filepath.Join(root, "services/api", Filename)) + raw, err := os.ReadFile(filepath.Join(root, Filename)) if err != nil { t.Fatal(err) } - if strings.Contains(string(raw), "[tasks.build]") { + if strings.Contains(string(raw), "[tasks.'api:build']") { t.Fatal("Go build fallback must not be generated without a Taskfile") } - if strings.Contains(string(raw), "[tasks.test]") { + if strings.Contains(string(raw), "[tasks.'api:test']") { t.Fatal("test task must come from Taskfile") } } diff --git a/packages/cli/internal/modules/miseconfig/trust_test.go b/packages/cli/internal/modules/miseconfig/trust_test.go index 699a2e2..37a91c4 100644 --- a/packages/cli/internal/modules/miseconfig/trust_test.go +++ b/packages/cli/internal/modules/miseconfig/trust_test.go @@ -36,6 +36,17 @@ func TestTrustOnlyCompleteGeneratedConfigurations(t *testing.T) { } unavailable := errors.New("runtime unavailable") probe := &trustProbe{prepare: func(c runtimeport.Command) (runtimeport.Command, error) { return c, unavailable }} + if err := plan.TrustGenerated(context.Background(), probe); err != nil || len(probe.commands) != 0 { + t.Fatalf("custom root must not be auto-trusted: %v %v", err, probe.commands) + } + writeFixture(t, filepath.Join(root, Filename), "") + plan, err = Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if err := plan.Apply(context.Background()); err != nil { + t.Fatal(err) + } if err := plan.TrustGenerated(context.Background(), probe); !errors.Is(err, unavailable) { t.Fatalf("lost preparation error: %v", err) } @@ -46,13 +57,14 @@ func TestTrustOnlyCompleteGeneratedConfigurations(t *testing.T) { } paths = append(paths, command.Argv[3]) } - if !reflect.DeepEqual(paths, []string{filepath.Join(plan.Root, "apps/web/mise.toml"), filepath.Join(plan.Root, "services/api/mise.toml")}) { + if !reflect.DeepEqual(paths, []string{filepath.Join(plan.Root, Filename)}) { t.Fatalf("trusted unexpected configs: %v", paths) } } func TestTrustRejectsFileChangedWhilePreparingRuntime(t *testing.T) { root := fixture(t) + writeFixture(t, filepath.Join(root, Filename), "") plan, err := Build(root, Options{}) if err != nil { t.Fatal(err) diff --git a/packages/cli/internal/modules/tasks/native.go b/packages/cli/internal/modules/tasks/native.go index 8898662..2cace04 100644 --- a/packages/cli/internal/modules/tasks/native.go +++ b/packages/cli/internal/modules/tasks/native.go @@ -83,14 +83,7 @@ func (s Service) Plan(ctx context.Context, w execution.Workspace, opts Options) task.Sources = item.Sources task.Outputs = item.Outputs task.Status = "unknown" - _, operation, _ := strings.Cut(item.Name, ":") - task.Operation = operation - for _, project := range w.Projects() { - if strings.HasPrefix(item.Name, "//"+project.RelativeDir+":") { - task.Project = project.Name - break - } - } + task.Project, task.Operation = taskIdentity(w, item.Name) task.Managed = false for _, command := range item.Run { if strings.HasPrefix(command, "one __task ") { diff --git a/packages/cli/internal/modules/tasks/plan.go b/packages/cli/internal/modules/tasks/plan.go index 74f8bfa..2ef267a 100644 --- a/packages/cli/internal/modules/tasks/plan.go +++ b/packages/cli/internal/modules/tasks/plan.go @@ -93,118 +93,116 @@ func Catalog(w execution.Workspace) ([]Task, *miseconfig.Plan, error) { overlay[change.Path] = []byte(change.After) } all := map[string]Task{} - dirs := map[string]string{"": ""} - for _, p := range w.Projects() { - dirs[p.RelativeDir] = p.Name + // Scheduling lives at the workspace root; package scripts and Taskfiles + // remain the command sources inside projects. + var includes []string + files, err := filepath.Glob(filepath.Join(w.Root(), ".mise/conf.d/*.toml")) + if err != nil { + return nil, nil, err } - for dir, project := range dirs { - var includes []string - files, err := filepath.Glob(filepath.Join(w.Root(), dir, ".mise/conf.d/*.toml")) - if err != nil { + sort.Strings(files) + for _, name := range []string{".mise.toml", "mise.toml", ".mise.local.toml", "mise.local.toml"} { + files = append(files, filepath.Join(w.Root(), name)) + } + for _, file := range files { + rel, _ := filepath.Rel(w.Root(), file) + rel = filepath.ToSlash(rel) + raw, ok := overlay[rel] + if !ok { + raw, err = os.ReadFile(file) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, nil, err + } + } + var doc struct { + Tasks map[string]any `toml:"tasks"` + TaskConfig struct { + Includes []string `toml:"includes"` + } `toml:"task_config"` + } + if err = toml.Unmarshal(raw, &doc); err != nil { return nil, nil, err } - sort.Strings(files) - for _, name := range []string{".mise.toml", "mise.toml", ".mise.local.toml", "mise.local.toml"} { - files = append(files, filepath.Join(w.Root(), dir, name)) + if doc.TaskConfig.Includes != nil { + includes = doc.TaskConfig.Includes } - for _, file := range files { - rel, _ := filepath.Rel(w.Root(), file) - rel = filepath.ToSlash(rel) - raw, ok := overlay[rel] + for name, value := range doc.Tasks { + fields, ok := value.(map[string]any) if !ok { - raw, err = os.ReadFile(file) - if os.IsNotExist(err) { - continue + fields = map[string]any{"run": value} + } + canonical := "//:" + name + task, exists := all[canonical] + if !exists { + project, operation := taskIdentity(w, canonical) + task = Task{Name: canonical, Project: project, Operation: operation, Directory: w.Root(), Status: "unknown", Dependencies: []string{}} + } + task.Source = rel + if v, ok := fields["description"].(string); ok { + task.Description = v + } + if v, ok := fields["run"]; ok { + task.Run = v + if command, ok := v.(string); ok { + task.Managed = strings.HasPrefix(command, "one __task ") + } else { + task.Managed = false } + } + if v, ok := fields["depends"]; ok { + task.Dependencies, err = stringList(v) if err != nil { - return nil, nil, err + return nil, nil, i18n.Errorf("tasks.dynamic_config", canonical) } } - var doc struct { - Tasks map[string]any `toml:"tasks"` - TaskConfig struct { - Includes []string `toml:"includes"` - } `toml:"task_config"` + if v, ok := fields["depends_post"]; ok { + post, e := stringList(v) + if e != nil || len(post) > 0 { + return nil, nil, i18n.Errorf("tasks.dynamic_config", canonical) + } } - if err = toml.Unmarshal(raw, &doc); err != nil { - return nil, nil, err + if v, ok := fields["sources"]; ok { + task.Sources, _ = stringList(v) } - if doc.TaskConfig.Includes != nil { - includes = doc.TaskConfig.Includes + if v, ok := fields["outputs"]; ok { + task.Outputs, _ = stringList(v) } - for name, value := range doc.Tasks { - fields, ok := value.(map[string]any) - if !ok { - fields = map[string]any{"run": value} - } - canonical := "//" + dir + ":" + name - task, exists := all[canonical] - if !exists { - task = Task{Name: canonical, Project: project, Operation: name, Directory: filepath.Join(w.Root(), dir), Status: "unknown", Dependencies: []string{}} - } - task.Source = rel - if v, ok := fields["description"].(string); ok { - task.Description = v - } - if v, ok := fields["run"]; ok { - task.Run = v - if command, ok := v.(string); ok { - task.Managed = strings.HasPrefix(command, "one __task ") - } else { - task.Managed = false - } - } - if v, ok := fields["depends"]; ok { - task.Dependencies, err = stringList(v) - if err != nil { - return nil, nil, i18n.Errorf("tasks.dynamic_config", canonical) - } - } - if v, ok := fields["depends_post"]; ok { - post, e := stringList(v) - if e != nil || len(post) > 0 { - return nil, nil, i18n.Errorf("tasks.dynamic_config", canonical) - } + if v, ok := fields["cache"].(map[string]any); ok { + if value, ok := v["enabled"].(bool); ok { + task.Cached = value } - if v, ok := fields["sources"]; ok { - task.Sources, _ = stringList(v) - } - if v, ok := fields["outputs"]; ok { - task.Outputs, _ = stringList(v) - } - if v, ok := fields["cache"].(map[string]any); ok { - if value, ok := v["enabled"].(bool); ok { - task.Cached = value - } - task.cacheInputs, _ = stringList(v["command_inputs"]) - } - if v, ok := fields["interactive"].(bool); ok { - task.Interactive = v - } - if v, ok := fields["raw"].(bool); ok { - task.Raw = v + task.cacheInputs, _ = stringList(v["command_inputs"]) + } + if v, ok := fields["interactive"].(bool); ok { + task.Interactive = v + } + if v, ok := fields["raw"].(bool); ok { + task.Raw = v + } + if v, ok := fields["dir"].(string); ok { + if strings.Contains(v, "{{") { + return nil, nil, i18n.Errorf("tasks.dynamic_config", canonical) } - if v, ok := fields["dir"].(string); ok { - if strings.Contains(v, "{{") { - return nil, nil, i18n.Errorf("tasks.dynamic_config", canonical) - } - if filepath.IsAbs(v) { - task.Directory = v - } else { - task.Directory = filepath.Join(w.Root(), dir, v) - } + if filepath.IsAbs(v) { + task.Directory = v + } else { + task.Directory = filepath.Join(w.Root(), v) } - all[canonical] = task } + all[canonical] = task } - scripts, err := fileTasks(w.Root(), dir, project, includes) - if err != nil { - return nil, nil, err - } - for name, task := range scripts { - if _, exists := all[name]; !exists { - all[name] = task - } + } + scripts, err := fileTasks(w.Root(), "", "", includes) + if err != nil { + return nil, nil, err + } + for name, task := range scripts { + task.Project, task.Operation = taskIdentity(w, name) + if _, exists := all[name]; !exists { + all[name] = task } } out := make([]Task, 0, len(all)) @@ -214,6 +212,21 @@ func Catalog(w execution.Workspace) ([]Task, *miseconfig.Plan, error) { sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, config, nil } + +// Project task names are namespaced in the single root configuration. +func taskIdentity(w execution.Workspace, canonical string) (string, string) { + name := strings.TrimPrefix(canonical, "//:") + project, operation, found := strings.Cut(name, ":") + if found { + for _, entry := range w.Projects() { + if entry.Name == project { + return project, operation + } + } + } + return "", name +} + func stringList(value any) ([]string, error) { if s, ok := value.(string); ok { return []string{s}, nil @@ -259,8 +272,7 @@ func planCatalog(w execution.Workspace, opts Options, catalog []Task, configurat return nil, err } for _, name := range names { - project, _ := w.Project(name) - p.Entries = append(p.Entries, "//"+project.RelativeDir+":"+opts.Name) + p.Entries = append(p.Entries, "//:"+name+":"+opts.Name) } } if len(p.Entries) > 1 && len(opts.Arguments) > 0 { diff --git a/packages/cli/internal/modules/tasks/plan_test.go b/packages/cli/internal/modules/tasks/plan_test.go index 246d60c..f629715 100644 --- a/packages/cli/internal/modules/tasks/plan_test.go +++ b/packages/cli/internal/modules/tasks/plan_test.go @@ -56,6 +56,25 @@ func TestPlanIncludesSharedDependenciesWithoutWrites(t *testing.T) { t.Fatal("unknown environment accepted") } } +func TestRootProjectTaskPreservesColonNameAndNativeDirectory(t *testing.T) { + w := taskWorkspace(t) + writeTaskFile(t, w.Root(), "apps/web/package.json", `{"scripts":{"docs:build":"echo docs"}}`) + p, err := NewPlan(w, Options{Name: "docs:build", Projects: []string{"web"}}) + if err != nil { + t.Fatal(err) + } + if len(p.Tasks) != 1 { + t.Fatal(p.Tasks) + } + task := p.Tasks[0] + if task.Name != "//:web:docs:build" || task.Operation != "docs:build" || task.Project != "web" || task.Source != "mise.toml" || task.Directory != filepath.Join(w.Root(), "apps/web") { + t.Fatalf("wrong project task identity or directory: %+v", task) + } + if len(p.ConfigChanges) != 1 || p.ConfigChanges[0].Path != "mise.toml" { + t.Fatalf("generated non-root configuration: %+v", p.ConfigChanges) + } +} + func TestPlanRejectsCycleOutputConflictsAndUnsafeCache(t *testing.T) { for _, tc := range []struct{ name, root, project string }{ {"cycle", `[tasks.build] @@ -87,8 +106,7 @@ dir="../elsewhere"`}, } { t.Run(tc.name, func(t *testing.T) { w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise.toml", tc.root) - writeTaskFile(t, w.Root(), "apps/web/mise.toml", tc.project) + writeTaskFile(t, w.Root(), "mise.toml", tc.root+"\n"+strings.ReplaceAll(tc.project, "[tasks.build]", "[tasks.\"web:build\"]")) if _, err := NewPlan(w, Options{Name: "build"}); err == nil { t.Fatal("invalid task plan accepted") } diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index a548cfb..ac10bfa 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -489,7 +489,7 @@ "hooks.flag.dry_run": "Preview configuration, migration, and local hook files without changing anything", "mise.flag.dry_run": "Print proposed file contents without writing files or executing mise", "mise.flag.node_version": "Exact Node version (default: previous generated version or 24.15.0)", - "mise.flag.go_version": "Exact Go version override (default: each project's go.mod)", + "mise.flag.go_version": "Exact workspace Go version (default: existing root pin or highest module requirement)", "hooks.migrated": "Husky/commitlint defaults were migrated. Run your package manager's install through one mise exec to refresh the dependency lockfile.", "hooks.existing_configuration": "The root hk.pkl takes precedence over .config/hk.pkl. Merge its settings into .config/hk.pkl and remove the root file before continuing.", "hooks.project_invalid": "invalid project name or directory", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 136d60f..5a16081 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -489,7 +489,7 @@ "hooks.flag.dry_run": "预览配置、迁移和本地钩子文件,不写入修改", "mise.flag.dry_run": "显示拟生成的文件内容,不写入文件或执行 mise", "mise.flag.node_version": "精确 Node 版本(默认沿用此前生成的版本,或使用 24.15.0)", - "mise.flag.go_version": "指定精确 Go 版本(默认读取各项目的 go.mod)", + "mise.flag.go_version": "工作区 Go 精确版本(默认:根目录已有固定版本或模块要求的最高版本)", "hooks.migrated": "Husky/commitlint 默认配置已迁移。请通过 one mise exec 运行包管理器的安装命令,以更新依赖锁文件。", "hooks.existing_configuration": "根目录 hk.pkl 会优先于 .config/hk.pkl 生效。请将设置合并到 .config/hk.pkl,并移除根文件后继续。", "hooks.project_invalid": "项目名称或目录无效", diff --git a/packages/cli/testdata/reference/help/init_mise.txt b/packages/cli/testdata/reference/help/init_mise.txt index 215ed8d..e6bd7f8 100644 --- a/packages/cli/testdata/reference/help/init_mise.txt +++ b/packages/cli/testdata/reference/help/init_mise.txt @@ -12,6 +12,6 @@ EXAMPLES COMMON OPTIONS --dry-run Print proposed file contents without writing files or executing mise - --go-version Exact Go version override (default: each project's go.mod) + --go-version Exact workspace Go version (default: existing root pin or highest module requirement) --node-version Exact Node version (default: previous generated version or 24.15.0) -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/tests/e2e/build_test.go b/packages/cli/tests/e2e/build_test.go index 2add5a4..ca13f2e 100644 --- a/packages/cli/tests/e2e/build_test.go +++ b/packages/cli/tests/e2e/build_test.go @@ -165,8 +165,8 @@ func TestE2E_GoLibraryTemplateHasBuildTask(t *testing.T) { if err != nil || !strings.Contains(string(raw), "go build {{.CLI_ARGS}} ./...") { t.Fatalf("%s %v", raw, err) } - raw, err = os.ReadFile(filepath.Join(ws, "packages/lib/mise.toml")) - if err != nil || !strings.Contains(string(raw), "[tasks.build]") { + raw, err = os.ReadFile(filepath.Join(ws, "mise.toml")) + if err != nil || !strings.Contains(string(raw), "[tasks.'lib:build']") { t.Fatalf("%s %v", raw, err) } } diff --git a/packages/cli/tests/e2e/mise_runtime_test.go b/packages/cli/tests/e2e/mise_runtime_test.go index a05e1b2..4c3dc7a 100644 --- a/packages/cli/tests/e2e/mise_runtime_test.go +++ b/packages/cli/tests/e2e/mise_runtime_test.go @@ -326,12 +326,17 @@ func TestE2E_MiseCreateAddAndRefreshWithoutNewFlags(t *testing.T) { t.Fatalf("add %s: %d %s %s", p.name, code, out, stderr) } } - for _, rel := range []string{"mise.toml", "apps/web/mise.toml", "services/api/mise.toml"} { + for _, rel := range []string{"mise.toml"} { raw, err := os.ReadFile(filepath.Join(root, rel)) if err != nil || !strings.Contains(string(raw), "# one:managed-v1") { t.Fatalf("configuration %s: %v %s", rel, err, raw) } } + for _, rel := range []string{"apps/web/mise.toml", "services/api/mise.toml"} { + if fileExists(t, filepath.Join(root, rel)) { + t.Fatalf("unexpected project configuration: %s", rel) + } + } out, stderr, code := runBinaryIn(t, root, "init", "mise", "--dry-run", "-o", "json") if code != 0 || !strings.Contains(out, `"changes": []`) { t.Fatalf("refresh: %d %s %s", code, out, stderr) @@ -340,10 +345,10 @@ func TestE2E_MiseCreateAddAndRefreshWithoutNewFlags(t *testing.T) { if code != 0 || !strings.Contains(out, `"runtime": "mise"`) { t.Fatalf("automatic runtime: %d %s %s", code, out, stderr) } - // A conflicting config_roots edit must fail before rendering a new project. + // A conflicting aggregate task edit must fail before rendering a new project. path := filepath.Join(root, "mise.toml") raw, _ := os.ReadFile(path) - if err := os.WriteFile(path, []byte(strings.Replace(string(raw), "config_roots = [", "config_roots = ['custom', ", 1)), 0o644); err != nil { + if err := os.WriteFile(path, []byte(strings.Replace(string(raw), "//:web:build", "//:custom:build", 1)), 0o644); err != nil { t.Fatal(err) } manifest, _ := os.ReadFile(filepath.Join(root, "one.manifest.json")) @@ -406,7 +411,7 @@ func TestE2E_MiseRealGeneratedTasksRequireOneContext(t *testing.T) { t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) for _, command := range []string{"echo live-first", "echo live-second"} { overrideDevCommand(t, root, "web", command) - cmd := exec.Command(mise, "run", "//apps/web:dev") + cmd := exec.Command(mise, "run", "//:web:dev") cmd.Dir = root out, err := cmd.CombinedOutput() if err == nil || !strings.Contains(string(out), "one run") { diff --git a/packages/cli/tests/e2e/mise_trust_test.go b/packages/cli/tests/e2e/mise_trust_test.go index b960627..346b579 100644 --- a/packages/cli/tests/e2e/mise_trust_test.go +++ b/packages/cli/tests/e2e/mise_trust_test.go @@ -43,7 +43,9 @@ func TestE2E_CreateAndAddTrustGeneratedMiseFiles(t *testing.T) { checkTrust(filepath.Join(parent, "mise.toml"), false) run(ws, "add", "react-spa", "--name", "web", "--yes", "-o", "json") checkTrust(filepath.Join(ws, "mise.toml"), true) - checkTrust(filepath.Join(ws, "apps/web/mise.toml"), true) + if fileExists(t, filepath.Join(ws, "apps/web/mise.toml")) { + t.Fatal("unexpected project mise configuration") + } // Adding a project must not grant trust to unrelated custom configuration. path := filepath.Join(ws, "mise.toml") raw, err := os.ReadFile(path) @@ -54,12 +56,18 @@ func TestE2E_CreateAndAddTrustGeneratedMiseFiles(t *testing.T) { checkTrust(path, false) run(ws, "add", "ts-library", "--name", "shared", "--yes", "-o", "json") checkTrust(path, false) - checkTrust(filepath.Join(ws, "packages/shared/mise.toml"), true) + if fileExists(t, filepath.Join(ws, "packages/shared/mise.toml")) { + t.Fatal("unexpected project mise configuration") + } preset := filepath.Join(parent, "preset") run(parent, "create", preset, "--preset", "1.frs.ltl", "--yes", "-o", "json") checkTrust(filepath.Join(preset, "mise.toml"), true) - checkTrust(filepath.Join(preset, "apps/react-spa/mise.toml"), true) - checkTrust(filepath.Join(preset, "packages/ts-library/mise.toml"), true) + if fileExists(t, filepath.Join(preset, "apps/react-spa/mise.toml")) { + t.Fatal("unexpected project mise configuration") + } + if fileExists(t, filepath.Join(preset, "packages/ts-library/mise.toml")) { + t.Fatal("unexpected project mise configuration") + } } func TestE2E_CreatePreservesFilesWhenMiseTrustFails(t *testing.T) { diff --git a/packages/cli/tests/e2e/repository_workspace_test.go b/packages/cli/tests/e2e/repository_workspace_test.go new file mode 100644 index 0000000..38e345a --- /dev/null +++ b/packages/cli/tests/e2e/repository_workspace_test.go @@ -0,0 +1,69 @@ +package cli_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The repository must remain bootstrappable without One, while project-scoped +// CLI tasks must prepare the resources and binary that Go alone cannot build. +func TestE2E_RepositoryWorkspaceTaskGraph(t *testing.T) { + isolateHome(t, t.TempDir()) + root := filepath.Clean(filepath.Join(repoRoot(t), "..", "..")) + for _, dir := range []string{"apps/dashboard", "apps/docs", "packages/cli", "packages/kernel"} { + if _, err := os.Stat(filepath.Join(root, dir, "mise.toml")); !os.IsNotExist(err) { + t.Fatalf("repository must use only root mise.toml: %s: %v", dir, err) + } + } + for _, tc := range []struct { + name string + args []string + required []string + bootstrap bool + }{ + {"bootstrap", []string{"install"}, []string{"//:build-local", "//:sync-bundled", "//:sync-web"}, true}, + {"gate", []string{"check"}, []string{"//:build", "//:test:go:plain", "//:test:dashboard"}, true}, + {"cli build", []string{"build", "-p", "cli"}, []string{"//:sync-bundled", "//:build-web", "//:sync-web", "//:cli:build"}, false}, + {"cli test", []string{"test", "-p", "cli"}, []string{"//:build", "//:cli:test"}, false}, + {"development", []string{"dev"}, []string{"//:dev:serve", "//:dev:dashboard"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append(append([]string{}, tc.args...), "--dry-run", "-o", "json") + out, logs, code := runBinaryIn(t, root, args...) + if code != 0 { + t.Fatal(code, out, logs) + } + var plan struct { + Tasks []struct { + Name string `json:"name"` + Managed bool `json:"managed"` + } `json:"tasks"` + Changes []json.RawMessage `json:"config_changes"` + } + if err := json.Unmarshal([]byte(out), &plan); err != nil { + t.Fatal(err) + } + if len(plan.Changes) != 0 { + t.Fatal("repository task configuration is stale; run one init mise") + } + names := map[string]bool{} + for _, task := range plan.Tasks { + names[task.Name] = true + if tc.bootstrap && task.Managed { + t.Errorf("native root workflow requires an installed One adapter: %s", task.Name) + } + if strings.Contains(task.Name, "packages/templates/") || strings.Contains(task.Name, "/testdata/") { + t.Errorf("source fixture became a runnable workspace project: %s", task.Name) + } + } + for _, name := range tc.required { + if !names[name] { + t.Errorf("missing prerequisite %s in %v", name, names) + } + } + }) + } +} diff --git a/packages/cli/tests/e2e/task_terminal_unix_test.go b/packages/cli/tests/e2e/task_terminal_unix_test.go index c83662e..301dd60 100644 --- a/packages/cli/tests/e2e/task_terminal_unix_test.go +++ b/packages/cli/tests/e2e/task_terminal_unix_test.go @@ -167,7 +167,8 @@ func TestE2E_BuildRawKeepsFailure(t *testing.T) { func TestE2E_DevHonorsMiseProjectOverrideAndRawTerminal(t *testing.T) { root := devTerminalFixture(t, true) - buildWrite(t, root, "packages/lib/mise.toml", `[tasks.dev] + appendRootTaskConfig(t, root, `[tasks."lib:dev"] +dir = "packages/lib" run = "sh override.sh" raw = true `) diff --git a/packages/cli/tests/e2e/tasks_cache_test.go b/packages/cli/tests/e2e/tasks_cache_test.go index 817e793..131e388 100644 --- a/packages/cli/tests/e2e/tasks_cache_test.go +++ b/packages/cli/tests/e2e/tasks_cache_test.go @@ -8,11 +8,21 @@ import ( "testing" ) -func TestE2E_TasksRestoreArtifactsAndInvalidateProjectConfiguration(t *testing.T) { +func appendRootTaskConfig(t *testing.T, root, value string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(root, "mise.toml")) + if err != nil { + t.Fatal(err) + } + buildWrite(t, root, "mise.toml", string(raw)+"\n"+value) +} + +func TestE2E_TasksRestoreArtifactsAndInvalidateRootConfiguration(t *testing.T) { root := buildFixture(t, true) - for name, dir := range map[string]string{"web": "apps/web", "lib": "packages/lib"} { + for _, project := range []struct{ name, dir string }{{"web", "apps/web"}, {"lib", "packages/lib"}} { + name, dir := project.name, project.dir buildWrite(t, root, dir+"/build.sh", "#!/bin/sh\nmkdir -p dist\nprintf '%s' \"$BUILD_VALUE\" > dist/value\necho executed >> executions\nprintf '<%s>\\n' \"$@\"\n") - buildWrite(t, root, dir+"/mise.toml", "[env]\nBUILD_VALUE='"+name+"'\n[tasks.build]\nsources=['package.json','build.sh','mise.toml']\noutputs=['dist']\ncache={enabled=true,command_inputs=['one __task-input --project "+name+" --task build']}\n") + appendRootTaskConfig(t, root, "[tasks.\""+name+":build\"]\nenv={BUILD_VALUE='"+name+"'}\nsources=['package.json','build.sh']\noutputs=['dist']\ncache={enabled=true,env=['BUILD_VALUE'],command_inputs=['one __task-input --project "+name+" --task build']}\n") } run := func(args ...string) string { t.Helper() @@ -41,7 +51,8 @@ func TestE2E_TasksRestoreArtifactsAndInvalidateProjectConfiguration(t *testing.T } } run("run", "build", "-p", "web", "-o", "json") - for name, dir := range map[string]string{"web": "apps/web", "lib": "packages/lib"} { + for _, project := range []struct{ name, dir string }{{"web", "apps/web"}, {"lib", "packages/lib"}} { + name, dir := project.name, project.dir raw, err := os.ReadFile(filepath.Join(root, dir, "dist/value")) if err != nil || string(raw) != name { t.Fatalf("restored environment %s: %s %v", name, raw, err) @@ -50,21 +61,27 @@ func TestE2E_TasksRestoreArtifactsAndInvalidateProjectConfiguration(t *testing.T t.Fatal("restore executed the task") } } - config, err := os.ReadFile(filepath.Join(root, "apps/web/mise.toml")) + config, err := os.ReadFile(filepath.Join(root, "mise.toml")) if err != nil { t.Fatal(err) } - buildWrite(t, root, "apps/web/mise.toml", strings.Replace(string(config), "BUILD_VALUE='web'", "BUILD_VALUE='changed'", 1)) + buildWrite(t, root, "mise.toml", strings.Replace(string(config), "BUILD_VALUE='web'", "BUILD_VALUE='changed'", 1)) run("run", "build", "-p", "web", "-o", "json") - if runs("apps/web") != 2 || runs("packages/lib") != 1 { - t.Fatal("project environments are not isolated") + // mise includes the defining config in every task cache key. Editing the + // shared root config invalidates both tasks, while their environments stay isolated. + if runs("apps/web") != 2 || runs("packages/lib") != 2 { + t.Fatalf("root configuration did not invalidate both tasks: web=%d lib=%d", runs("apps/web"), runs("packages/lib")) } raw, _ := os.ReadFile(filepath.Join(root, "apps/web/dist/value")) if string(raw) != "changed" { t.Fatal("stale environment cache hit") } + lib, _ := os.ReadFile(filepath.Join(root, "packages/lib/dist/value")) + if string(lib) != "lib" { + t.Fatal("project environments are not isolated") + } run("run", "build", "-p", "web", "--force", "--cache", "off") - if runs("apps/web") != 3 || runs("packages/lib") != 2 { + if runs("apps/web") != 3 || runs("packages/lib") != 3 { t.Fatal("forced execution skipped tasks") } out := run("run", "build", "-p", "web", "--", "a b", "中文", "$(nope)") @@ -99,7 +116,7 @@ func TestE2E_NativeMiseFileTask(t *testing.T) { func TestE2E_TasksRejectCacheWithoutEnvironmentFingerprint(t *testing.T) { root := buildFixture(t, true) - buildWrite(t, root, "apps/web/mise.toml", "[tasks.build]\nsources=['package.json']\noutputs=['dist']\ncache={enabled=true}\n") + appendRootTaskConfig(t, root, "[tasks.\"web:build\"]\nsources=['package.json']\noutputs=['dist']\ncache={enabled=true}\n") stdout, stderr, code := runBinaryIn(t, root, "run", "build", "-p", "web", "-o", "json") if code == 0 || !strings.Contains(stderr+stdout, "__task-input") { t.Fatalf("effective cache without fingerprint accepted: %d %s %s", code, stdout, stderr) @@ -125,9 +142,10 @@ func TestE2E_TasksReuseArtifactsAcrossCheckouts(t *testing.T) { t.Setenv("MISE_TASK_CACHE_DIR", t.TempDir()) for i := 0; i < 2; i++ { root := buildFixture(t, true) - for name, dir := range map[string]string{"web": "apps/web", "lib": "packages/lib"} { + for _, project := range []struct{ name, dir string }{{"web", "apps/web"}, {"lib", "packages/lib"}} { + name, dir := project.name, project.dir buildWrite(t, root, dir+"/build.sh", "#!/bin/sh\nmkdir -p dist\necho portable-artifact > dist/value\necho executed >> executions\n") - buildWrite(t, root, dir+"/mise.toml", "[tasks.build]\nsources=['package.json','build.sh']\noutputs=['dist']\ncache={enabled=true,command_inputs=['one __task-input --project "+name+" --task build']}\n") + appendRootTaskConfig(t, root, "[tasks.\""+name+":build\"]\nsources=['package.json','build.sh']\noutputs=['dist']\ncache={enabled=true,command_inputs=['one __task-input --project "+name+" --task build']}\n") } stdout, stderr, code := runBinaryIn(t, root, "build", "-o", "json") if code != 0 { @@ -164,7 +182,6 @@ func TestE2E_GoTaskBuildRestoresExecutableAndForwardsArguments(t *testing.T) { t.Setenv("CGO_ENABLED", "0") buildWrite(t, root, "one.manifest.json", `{"version":1,"workspace":{"id":"go-test","name":"go-test"},"projects":[{"name":"api","relativeDir":"services/api","toolchain":"go","templateId":"go-api"}]}`) buildWrite(t, root, "mise.toml", "[tools]\ngo='system'\ntask='system'\nnode='system'\npnpm='system'\n") - buildWrite(t, root, "services/api/mise.toml", "[tools]\ngo='system'\ntask='system'\n") buildWrite(t, root, "services/api/go.mod", "module example.com/task-test\n\ngo 1.25.0\n") buildWrite(t, root, "services/api/cmd/server/main.go", "package main\nimport \"fmt\"\nvar message=\"default\"\nfunc main(){fmt.Print(message)}\n") scaffold, err := os.ReadFile(filepath.Join(repoRoot(t), "../templates/go-api/Taskfile.yml")) diff --git a/seo.manifest.json b/seo.manifest.json deleted file mode 100644 index ac3a428..0000000 --- a/seo.manifest.json +++ /dev/null @@ -1,1879 +0,0 @@ -{ - "version": "0.1", - "site": { - "url": "https://1cli.dev", - "type": "docs", - "language": "en", - "gsc_property": "sc-domain:1cli.dev" - }, - "stage": { - "name": "no_data", - "impressions_90d": 104, - "updated_at": "2026-06-15T14:13:21.80712Z" - }, - "pages": [], - "keywords": [ - { - "query": "one cli", - "status": "validating", - "source": "gsc", - "page": "https://1cli.dev/en/", - "business_value": "unknown", - "reason": "Discovered in Google Search Console; review business fit before selecting it as a target.", - "evidence": { - "average_position": 6.925925925925926, - "clicks": 0, - "impressions": 27, - "pages": [ - "https://1cli.dev/en/", - "https://www.1cli.dev/en/" - ] - }, - "created_at": "2026-05-29T05:08:14.95008Z", - "updated_at": "2026-05-29T05:19:17.223249Z" - }, - { - "query": "one-cli", - "status": "candidate", - "source": "gsc", - "page": "https://1cli.dev/en/", - "business_value": "unknown", - "reason": "Discovered in Google Search Console; review business fit before selecting it as a target.", - "evidence": { - "average_position": 15.666666666666666, - "clicks": 1, - "impressions": 3, - "pages": [ - "https://1cli.dev/en/", - "https://www.1cli.dev/en/" - ] - }, - "created_at": "2026-05-29T05:08:14.95008Z", - "updated_at": "2026-05-29T05:19:17.223249Z" - }, - { - "query": "aone cli", - "status": "candidate", - "source": "gsc", - "page": "https://1cli.dev/en/", - "business_value": "unknown", - "reason": "Discovered in Google Search Console; review business fit before selecting it as a target.", - "evidence": { - "average_position": 4.333333333333333, - "clicks": 0, - "impressions": 3, - "pages": [ - "https://1cli.dev/en/" - ] - }, - "created_at": "2026-05-29T05:08:14.95008Z", - "updated_at": "2026-05-29T05:19:17.223249Z" - }, - { - "query": "ones cli", - "status": "candidate", - "source": "gsc", - "page": "https://1cli.dev/en/", - "business_value": "unknown", - "reason": "Discovered in Google Search Console; review business fit before selecting it as a target.", - "evidence": { - "average_position": 3, - "clicks": 0, - "impressions": 2, - "pages": [ - "https://1cli.dev/en/" - ] - }, - "created_at": "2026-05-29T05:08:14.95008Z", - "updated_at": "2026-05-29T05:19:17.223249Z" - }, - { - "query": "onetcli", - "status": "candidate", - "source": "gsc", - "page": "https://1cli.dev/en/", - "business_value": "unknown", - "reason": "Discovered in Google Search Console; review business fit before selecting it as a target.", - "evidence": { - "average_position": 22, - "clicks": 0, - "impressions": 2, - "pages": [ - "https://1cli.dev/en/" - ] - }, - "created_at": "2026-05-29T05:08:14.95008Z", - "updated_at": "2026-05-29T05:19:17.223249Z" - }, - { - "query": "1cli", - "status": "candidate", - "source": "gsc", - "page": "https://1cli.dev/en/", - "business_value": "unknown", - "reason": "Discovered in Google Search Console; review business fit before selecting it as a target.", - "evidence": { - "average_position": 2, - "clicks": 0, - "impressions": 1, - "pages": [ - "https://www.1cli.dev/en/" - ] - }, - "created_at": "2026-05-29T05:08:14.95008Z", - "updated_at": "2026-05-29T05:19:17.223249Z" - }, - { - "query": "ai cli", - "status": "selected", - "source": "keyword-planner", - "intent": "commercial", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 1900, - "competition": "低", - "csv_row": 4, - "evidence_source": "keyword-planner", - "three_month_change": 53, - "top_of_page_bid_high": 57.78, - "top_of_page_bid_low": 5.77, - "year_over_year_change": 504 - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T05:19:26.309693Z" - }, - { - "query": "codex cli", - "status": "candidate", - "source": "keyword-planner", - "intent": "commercial", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 110000, - "competition": "低", - "csv_row": 5, - "evidence_source": "keyword-planner", - "three_month_change": 49, - "top_of_page_bid_high": 2413.91, - "top_of_page_bid_low": 15.28, - "year_over_year_change": 232 - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T05:19:21.598807Z" - }, - { - "query": "cli ai", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 1600, - "competition": "低", - "csv_row": 7, - "evidence_source": "keyword-planner", - "three_month_change": 53, - "top_of_page_bid_high": 57.96, - "top_of_page_bid_low": 5.65, - "year_over_year_change": 806 - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "ai coding cli", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 170, - "competition": "中", - "csv_row": 41, - "evidence_source": "keyword-planner", - "three_month_change": -19, - "top_of_page_bid_high": 59.31, - "top_of_page_bid_low": 5.08, - "year_over_year_change": "∞" - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "claude cli", - "status": "candidate", - "source": "keyword-planner", - "intent": "commercial", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 60500, - "competition": "低", - "csv_row": 8, - "evidence_source": "keyword-planner", - "three_month_change": 49, - "top_of_page_bid_high": 156.06, - "top_of_page_bid_low": 9.89, - "year_over_year_change": 1945 - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T05:19:21.598807Z" - }, - { - "query": "monorepo cli", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": "--", - "csv_row": 42, - "evidence_source": "keyword-planner", - "three_month_change": "--", - "year_over_year_change": "未知" - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "developer workspace cli", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": "--", - "csv_row": 43, - "evidence_source": "keyword-planner", - "three_month_change": "--", - "year_over_year_change": "未知" - }, - "created_at": "2026-05-29T05:19:21.598807Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 22200, - "competition": "低", - "csv_row": 5, - "evidence_source": "keyword-planner", - "three_month_change": 22, - "top_of_page_bid_high": 100.82, - "top_of_page_bid_low": 4.8, - "year_over_year_change": 22 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "mono repo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 3600, - "competition": "低", - "csv_row": 6, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 88.85, - "top_of_page_bid_low": 5.27, - "year_over_year_change": -18 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo architecture", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 1000, - "competition": "低", - "csv_row": 8, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 70.06, - "top_of_page_bid_low": 11.58, - "year_over_year_change": 30 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo structure", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 480, - "competition": "低", - "csv_row": 9, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 390, - "top_of_page_bid_low": 390, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo benefits", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 40, - "competition": "低", - "csv_row": 10, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 50, - "top_of_page_bid_low": 50, - "year_over_year_change": -20 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "js monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 90, - "competition": "低", - "csv_row": 11, - "evidence_source": "keyword-planner", - "three_month_change": -29, - "top_of_page_bid_high": 110, - "top_of_page_bid_low": 110, - "year_over_year_change": -64 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo structure example", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 30, - "competition": "低", - "csv_row": 12, - "evidence_source": "keyword-planner", - "three_month_change": -25, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 30, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo setup", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 140, - "competition": "低", - "csv_row": 13, - "evidence_source": "keyword-planner", - "three_month_change": 27, - "top_of_page_bid_high": 140, - "top_of_page_bid_low": 110, - "year_over_year_change": 56 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "benefits of monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 50, - "competition": "低", - "csv_row": 14, - "evidence_source": "keyword-planner", - "three_month_change": 75, - "top_of_page_bid_high": 50, - "top_of_page_bid_low": 70, - "year_over_year_change": 40 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo management", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 50, - "competition": "低", - "csv_row": 15, - "evidence_source": "keyword-planner", - "three_month_change": -44, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 50, - "year_over_year_change": -29 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo advantages", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 40, - "competition": "低", - "csv_row": 16, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 70, - "top_of_page_bid_low": 50, - "year_over_year_change": -20 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "benefits of a monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 20, - "competition": "低", - "csv_row": 17, - "evidence_source": "keyword-planner", - "three_month_change": -33, - "top_of_page_bid_high": 20, - "top_of_page_bid_low": 30, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "best monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 40, - "competition": "低", - "csv_row": 18, - "evidence_source": "keyword-planner", - "three_month_change": -25, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 40, - "year_over_year_change": -25 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "create a monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 20, - "competition": "低", - "csv_row": 19, - "evidence_source": "keyword-planner", - "three_month_change": 100, - "top_of_page_bid_high": 10, - "top_of_page_bid_low": 30, - "year_over_year_change": -33 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "create monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 30, - "competition": "低", - "csv_row": 20, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 40, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "define monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 110, - "competition": "低", - "csv_row": 21, - "evidence_source": "keyword-planner", - "three_month_change": 56, - "top_of_page_bid_high": 110, - "top_of_page_bid_low": 110, - "year_over_year_change": 56 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "mono repo architecture", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 1000, - "competition": "低", - "csv_row": 22, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 70.06, - "top_of_page_bid_low": 11.58, - "year_over_year_change": 30 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "mono repo meaning", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 320, - "competition": "低", - "csv_row": 23, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 260, - "top_of_page_bid_low": 320, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "mono repository structure", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 480, - "competition": "低", - "csv_row": 24, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 390, - "top_of_page_bid_low": 390, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo approach", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 40, - "competition": "低", - "csv_row": 25, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 40, - "top_of_page_bid_low": 30, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo build", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 20, - "competition": "低", - "csv_row": 26, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 20, - "top_of_page_bid_low": 20, - "year_over_year_change": -50 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo concept", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 20, - "competition": "低", - "csv_row": 27, - "evidence_source": "keyword-planner", - "three_month_change": -50, - "top_of_page_bid_high": 20, - "top_of_page_bid_low": 30, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo definition", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 70, - "competition": "低", - "csv_row": 28, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 90, - "top_of_page_bid_low": 70, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo example", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 210, - "competition": "低", - "csv_row": 29, - "evidence_source": "keyword-planner", - "three_month_change": -18, - "top_of_page_bid_high": 210, - "top_of_page_bid_low": 210, - "year_over_year_change": -46 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo explained", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 30, - "competition": "低", - "csv_row": 30, - "evidence_source": "keyword-planner", - "three_month_change": 100, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 20, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo javascript", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 90, - "competition": "低", - "csv_row": 31, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 110, - "top_of_page_bid_low": 110, - "year_over_year_change": -36 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo manager", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 30, - "competition": "低", - "csv_row": 32, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 30, - "year_over_year_change": -33 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo meaning", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 720, - "competition": "低", - "csv_row": 33, - "evidence_source": "keyword-planner", - "three_month_change": 14, - "top_of_page_bid_high": 590, - "top_of_page_bid_low": 480, - "year_over_year_change": 69 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo packages", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 40, - "competition": "低", - "csv_row": 34, - "evidence_source": "keyword-planner", - "three_month_change": -25, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 30, - "year_over_year_change": -40 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo pattern", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 30, - "competition": "低", - "csv_row": 35, - "evidence_source": "keyword-planner", - "three_month_change": 33, - "top_of_page_bid_high": 30, - "top_of_page_bid_low": 30, - "year_over_year_change": 100 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo strategy", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 20, - "competition": "低", - "csv_row": 36, - "evidence_source": "keyword-planner", - "three_month_change": -50, - "top_of_page_bid_high": 20, - "top_of_page_bid_low": 20, - "year_over_year_change": -50 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo version control", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 10, - "competition": "低", - "csv_row": 37, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 10, - "top_of_page_bid_low": 10, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo what is", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 10, - "competition": "低", - "csv_row": 38, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 10, - "top_of_page_bid_low": 10, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "node js monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 40, - "competition": "低", - "csv_row": 39, - "evidence_source": "keyword-planner", - "three_month_change": 50, - "top_of_page_bid_high": 50, - "top_of_page_bid_low": 70, - "year_over_year_change": -57 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "setup monorepo", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 20, - "competition": "低", - "csv_row": 40, - "evidence_source": "keyword-planner", - "three_month_change": 50, - "top_of_page_bid_high": 20, - "top_of_page_bid_low": 20, - "year_over_year_change": 0 - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "monorepo ai", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 10, - "competition": "低", - "csv_row": 44, - "evidence_source": "keyword-planner", - "three_month_change": 67, - "top_of_page_bid_high": 83.3, - "top_of_page_bid_low": 10.77, - "year_over_year_change": "∞" - }, - "created_at": "2026-05-29T07:07:47.650523Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "agentic engineering", - "status": "candidate", - "source": "keyword-planner", - "intent": "informational", - "page": "https://1cli.dev/en/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 3600, - "competition": "中", - "csv_row": 45, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 51.78, - "top_of_page_bid_low": 5.38, - "year_over_year_change": 3003 - }, - "created_at": "2026-05-29T07:10:09.659923Z", - "updated_at": "2026-05-29T07:10:09.659923Z" - }, - { - "query": "agentic engineering", - "status": "selected", - "source": "keyword-planner", - "intent": "informational", - "page": "https://www.1cli.dev/en/blog/agentic-engineering/", - "business_value": "high", - "reason": "Imported from keyword-planner CSV; review intent and business fit before selecting it as a target.", - "evidence": { - "avg_monthly_searches": 3600, - "competition": "中", - "csv_row": 45, - "evidence_source": "keyword-planner", - "three_month_change": 0, - "top_of_page_bid_high": 51.78, - "top_of_page_bid_low": 5.38, - "year_over_year_change": 3003 - }, - "created_at": "2026-05-29T07:10:09.659923Z", - "updated_at": "2026-06-15T15:31:21.980975Z" - } - ], - "targets": [ - { - "page": "https://1cli.dev/en/", - "primary_query": "ai cli", - "intent": "commercial", - "evidence_source": "keyword-planner", - "business_value": "high", - "created_at": "2026-05-29T05:19:26.309693Z" - }, - { - "page": "https://www.1cli.dev/en/blog/agentic-engineering/", - "primary_query": "agentic engineering", - "intent": "informational", - "evidence_source": "keyword-planner", - "business_value": "high", - "created_at": "2026-06-15T15:31:21.980975Z" - } - ], - "opportunities": [ - { - "id": "opp_e2ac47ff1c63", - "rule_id": "gsc.low_ctr.v1", - "type": "opportunity", - "page": "https://1cli.dev/en/", - "query": "one cli", - "priority": "high", - "confidence": "medium", - "evidence": { - "clicks": 0, - "ctr": 0, - "impressions": 19, - "position": 6.631578947368421 - }, - "recommended_actions": [ - "rewrite_title", - "rewrite_meta_description", - "improve_intro_summary" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_890af78e3d9f", - "rule_id": "url.canonical_split.v1", - "type": "blocker", - "page": "https://1cli.dev/en/", - "priority": "high", - "confidence": "high", - "evidence": { - "canonical_key": "1cli.dev/en", - "variants": [ - "https://1cli.dev/en/", - "https://www.1cli.dev/en/" - ] - }, - "recommended_actions": [ - "choose_canonical_url", - "add_or_fix_301_redirect", - "fix_canonical_tag", - "fix_sitemap_urls", - "fix_internal_links" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_793033740360", - "rule_id": "url.canonical_split.v1", - "type": "blocker", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "priority": "high", - "confidence": "high", - "evidence": { - "canonical_key": "1cli.dev/en/tutorials/container-build-push", - "variants": [ - "https://www.1cli.dev/en/tutorials/container-build-push", - "https://www.1cli.dev/en/tutorials/container-build-push/" - ] - }, - "recommended_actions": [ - "choose_canonical_url", - "add_or_fix_301_redirect", - "fix_canonical_tag", - "fix_sitemap_urls", - "fix_internal_links" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_0c1d7de1a252", - "rule_id": "gsc.cannibalization.v1", - "type": "opportunity", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "query": "push image", - "priority": "medium", - "confidence": "medium", - "evidence": { - "pages": [ - "https://www.1cli.dev/en/tutorials/container-build-push", - "https://www.1cli.dev/en/tutorials/container-build-push/" - ], - "query": "push image", - "total_impressions": 2 - }, - "recommended_actions": [ - "choose_primary_target_page", - "reduce_topic_overlap", - "add_internal_links_to_primary_page", - "consider_merge_or_canonical" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_f417a5b1cff1", - "rule_id": "gsc.cannibalization.v1", - "type": "opportunity", - "page": "https://1cli.dev/en/", - "query": "one cli", - "priority": "medium", - "confidence": "medium", - "evidence": { - "pages": [ - "https://1cli.dev/en/", - "https://www.1cli.dev/en/" - ], - "query": "one cli", - "total_impressions": 27 - }, - "recommended_actions": [ - "choose_primary_target_page", - "reduce_topic_overlap", - "add_internal_links_to_primary_page", - "consider_merge_or_canonical" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_dea361b6094c", - "rule_id": "gsc.cannibalization.v1", - "type": "opportunity", - "page": "https://1cli.dev/en/", - "query": "one-cli", - "priority": "medium", - "confidence": "medium", - "evidence": { - "pages": [ - "https://1cli.dev/en/", - "https://www.1cli.dev/en/" - ], - "query": "one-cli", - "total_impressions": 3 - }, - "recommended_actions": [ - "choose_primary_target_page", - "reduce_topic_overlap", - "add_internal_links_to_primary_page", - "consider_merge_or_canonical" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_66e122301795", - "rule_id": "keyword.competitor_brand.v1", - "type": "review", - "page": "https://1cli.dev/en/", - "query": "codex cli", - "priority": "medium", - "confidence": "medium", - "evidence": { - "avg_monthly_searches": 110000, - "business_value": "high", - "detected_brands": [ - "codex" - ], - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "query": "codex cli" - }, - "recommended_actions": [ - "do_not_use_as_homepage_primary_query", - "consider_comparison_or_integration_page", - "require_human_review_before_target_selection" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_795aaa2e80e8", - "rule_id": "keyword.competitor_brand.v1", - "type": "review", - "page": "https://1cli.dev/en/", - "query": "claude cli", - "priority": "medium", - "confidence": "medium", - "evidence": { - "avg_monthly_searches": 60500, - "business_value": "high", - "detected_brands": [ - "claude" - ], - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "query": "claude cli" - }, - "recommended_actions": [ - "do_not_use_as_homepage_primary_query", - "consider_comparison_or_integration_page", - "require_human_review_before_target_selection" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_8910da7b2028", - "rule_id": "keyword.page_intent_mismatch.v1", - "type": "review", - "page": "https://1cli.dev/en/", - "query": "monorepo setup", - "priority": "medium", - "confidence": "medium", - "evidence": { - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "page_type": "homepage", - "query": "monorepo setup", - "query_intent": "how_to" - }, - "recommended_actions": [ - "assign_keyword_to_better_page_type", - "create_or_choose_supporting_page", - "avoid_forcing_homepage_to_match_how_to_queries" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_d10322020866", - "rule_id": "keyword.page_intent_mismatch.v1", - "type": "review", - "page": "https://1cli.dev/en/", - "query": "setup monorepo", - "priority": "medium", - "confidence": "medium", - "evidence": { - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "page_type": "homepage", - "query": "setup monorepo", - "query_intent": "how_to" - }, - "recommended_actions": [ - "assign_keyword_to_better_page_type", - "create_or_choose_supporting_page", - "avoid_forcing_homepage_to_match_how_to_queries" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_813f832378f0", - "rule_id": "target.keyword_fit_review.v1", - "type": "review", - "page": "https://1cli.dev/en/", - "query": "ai cli", - "priority": "low", - "confidence": "medium", - "evidence": { - "factors": { - "avg_monthly_searches": 1900, - "gsc_validated": false, - "page_intent_fit": true, - "page_type": "homepage", - "product_fit_score": 10, - "query_intent": "product" - }, - "score": 65, - "target_query": "ai cli" - }, - "recommended_actions": [ - "review_keyword_fit_score", - "confirm_or_change_primary_target_query", - "keep_target_as_experiment_until_gsc_validates_it" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - }, - { - "id": "opp_97ad0745443e", - "rule_id": "target.no_gsc_signal.v1", - "type": "experiment", - "page": "https://1cli.dev/en/", - "query": "ai cli", - "priority": "low", - "confidence": "low", - "evidence": { - "evidence_source": "keyword-planner", - "target_query": "ai cli" - }, - "recommended_actions": [ - "strengthen_target_query_match", - "add_internal_links", - "review_search_intent" - ], - "created_at": "2026-05-29T09:29:41.521706Z" - } - ], - "experiments": [ - { - "id": "exp_20260529_2aea336c95", - "page": "https://1cli.dev/en/", - "query": "one cli", - "change_summary": "fixed homepage canonical/title/meta", - "started_at": "2026-05-29T08:34:08.079452Z", - "measurement_window_days": 28, - "status": "measuring" - }, - { - "id": "exp_20260529_ff52e43ac5", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "query": "push image", - "change_summary": "added www/non-www and legacy path redirects in root and docs Vercel configs", - "started_at": "2026-05-29T08:44:33.887215Z", - "measurement_window_days": 28, - "status": "measuring" - }, - { - "id": "exp_20260529_fa09a7c30b", - "page": "https://1cli.dev/en/", - "query": "one-cli", - "change_summary": "homepage canonical/title/meta also covers one-cli brand variant", - "started_at": "2026-05-29T08:45:14.061224Z", - "measurement_window_days": 28, - "status": "measuring" - }, - { - "id": "exp_20260615_0999e14314", - "page": "https://www.1cli.dev/en/blog/agentic-engineering/", - "query": "agentic engineering", - "change_summary": "strengthen_target_query_match / target agentic engineering", - "started_at": "2026-06-15T15:27:44.901389Z", - "measurement_window_days": 28, - "status": "measuring" - }, - { - "id": "exp_20260615_81c1c4386d", - "page": "https://www.1cli.dev/en/blog/agentic-engineering/", - "query": "agentic engineering", - "change_summary": "review_keyword_fit_score / target agentic engineering", - "started_at": "2026-06-15T15:35:36.60935Z", - "measurement_window_days": 28, - "status": "measuring" - } - ], - "reports": [ - { - "id": "report_2026-05-27", - "type": "weekly", - "period_start": "2026-05-21", - "period_end": "2026-05-27", - "created_at": "2026-05-29T09:29:41.521706Z", - "summary": { - "average_position_delta": -10.080459770114944, - "clicks_delta": -1, - "ctr_delta": -0.06666666666666667, - "current": { - "average_position": 8.586206896551724, - "clicks": 0, - "ctr": 0, - "impressions": 29 - }, - "impressions_delta": 14, - "previous": { - "average_position": 18.666666666666668, - "clicks": 1, - "ctr": 0.06666666666666667, - "impressions": 15 - } - } - }, - { - "id": "daily_2026-05-27", - "type": "daily", - "period_start": "2026-05-27", - "period_end": "2026-05-27", - "created_at": "2026-05-29T09:29:41.521706Z", - "summary": { - "daily_plan": { - "do_now": [ - { - "action": "separate_competitor_keyword_strategy", - "commands": [ - "seo plan --page 'https://1cli.dev/en/' -o json", - "seo experiments start --page 'https://1cli.dev/en/' --query 'codex cli' --change 'separate_competitor_keyword_strategy' --window 28" - ], - "evidence": { - "avg_monthly_searches": 110000, - "business_value": "high", - "detected_brands": [ - "codex" - ], - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "query": "codex cli" - }, - "id": "dp_4f5452dd02f6", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "codex cli", - "success_metric": "Review GSC after the measurement window.", - "type": "page_fix", - "why_now": "The keyword includes a third-party brand; avoid making it a homepage primary target by accident." - }, - { - "action": "separate_competitor_keyword_strategy", - "commands": [ - "seo plan --page 'https://1cli.dev/en/' -o json", - "seo experiments start --page 'https://1cli.dev/en/' --query 'claude cli' --change 'separate_competitor_keyword_strategy' --window 28" - ], - "evidence": { - "avg_monthly_searches": 60500, - "business_value": "high", - "detected_brands": [ - "claude" - ], - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "query": "claude cli" - }, - "id": "dp_f70316b7e3ab", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "claude cli", - "success_metric": "Review GSC after the measurement window.", - "type": "page_fix", - "why_now": "The keyword includes a third-party brand; avoid making it a homepage primary target by accident." - }, - { - "action": "assign_keyword_to_better_page_type", - "commands": [ - "seo plan --page 'https://1cli.dev/en/' -o json", - "seo experiments start --page 'https://1cli.dev/en/' --query 'monorepo setup' --change 'assign_keyword_to_better_page_type' --window 28" - ], - "evidence": { - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "page_type": "homepage", - "query": "monorepo setup", - "query_intent": "how_to" - }, - "id": "dp_068558c56072", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "monorepo setup", - "success_metric": "Review GSC after the measurement window.", - "type": "page_fix", - "why_now": "The keyword appears mapped to a page type that may not satisfy search intent." - }, - { - "action": "assign_keyword_to_better_page_type", - "commands": [ - "seo plan --page 'https://1cli.dev/en/' -o json", - "seo experiments start --page 'https://1cli.dev/en/' --query 'setup monorepo' --change 'assign_keyword_to_better_page_type' --window 28" - ], - "evidence": { - "keyword_source": "keyword-planner", - "keyword_status": "candidate", - "page_type": "homepage", - "query": "setup monorepo", - "query_intent": "how_to" - }, - "id": "dp_5fc414bdb7f3", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "setup monorepo", - "success_metric": "Review GSC after the measurement window.", - "type": "page_fix", - "why_now": "The keyword appears mapped to a page type that may not satisfy search intent." - }, - { - "action": "review_keyword_fit_score", - "commands": [ - "seo plan --page 'https://1cli.dev/en/' -o json", - "seo experiments start --page 'https://1cli.dev/en/' --query 'ai cli' --change 'review_keyword_fit_score' --window 28" - ], - "evidence": { - "factors": { - "avg_monthly_searches": 1900, - "gsc_validated": false, - "page_intent_fit": true, - "page_type": "homepage", - "product_fit_score": 10, - "query_intent": "product" - }, - "score": 65, - "target_query": "ai cli" - }, - "id": "dp_5b19defa2a7e", - "page": "https://1cli.dev/en/", - "priority": "low", - "query": "ai cli", - "success_metric": "The target is confirmed, changed, or kept as a measured experiment.", - "type": "page_fix", - "why_now": "The selected target query is still a hypothesis and needs review before more edits." - } - ], - "publish_next": [ - { - "action": "create_supporting_content", - "evidence": { - "avg_monthly_searches": 3600, - "business_value": "high", - "gsc_impressions": 0, - "intent": "informational", - "source": "keyword-planner", - "source_page": "https://1cli.dev/en/" - }, - "format": "blog", - "id": "dp_4668fccb722f", - "priority": "high", - "query": "agentic engineering", - "success_metric": "GSC starts showing impressions for this query/page within 28-90 days.", - "suggested_path": "/en/blog/agentic-engineering", - "title": "Agentic Engineering Needs Structured Workspaces", - "type": "content_idea", - "why_now": "Keyword Planner shows 3600 average monthly searches; use a supporting page instead of repeatedly changing an existing page." - }, - { - "action": "create_supporting_content", - "evidence": { - "avg_monthly_searches": 1600, - "business_value": "high", - "gsc_impressions": 0, - "intent": "informational", - "source": "keyword-planner", - "source_page": "https://1cli.dev/en/" - }, - "format": "blog", - "id": "dp_ec6b53b24d25", - "priority": "high", - "query": "cli ai", - "success_metric": "GSC starts showing impressions for this query/page within 28-90 days.", - "suggested_path": "/en/blog/cli-ai", - "title": "AI CLI vs Project CLI", - "type": "content_idea", - "why_now": "Keyword Planner shows 1600 average monthly searches; use a supporting page instead of repeatedly changing an existing page." - }, - { - "action": "create_supporting_content", - "evidence": { - "avg_monthly_searches": 1000, - "business_value": "high", - "gsc_impressions": 0, - "intent": "informational", - "source": "keyword-planner", - "source_page": "https://1cli.dev/en/" - }, - "format": "guide", - "id": "dp_8f5808763bbe", - "priority": "high", - "query": "mono repo architecture", - "success_metric": "GSC starts showing impressions for this query/page within 28-90 days.", - "suggested_path": "/en/guides/mono-repo-architecture", - "title": "Monorepo Architecture for AI Coding Agents", - "type": "content_idea", - "why_now": "Keyword Planner shows 1000 average monthly searches; use a supporting page instead of repeatedly changing an existing page." - }, - { - "action": "create_supporting_content", - "evidence": { - "avg_monthly_searches": 720, - "business_value": "high", - "gsc_impressions": 0, - "intent": "informational", - "source": "keyword-planner", - "source_page": "https://1cli.dev/en/" - }, - "format": "blog", - "id": "dp_449d11870f06", - "priority": "medium", - "query": "monorepo meaning", - "success_metric": "GSC starts showing impressions for this query/page within 28-90 days.", - "suggested_path": "/en/blog/monorepo-meaning", - "title": "What Is Monorepo Meaning?", - "type": "content_idea", - "why_now": "Keyword Planner shows 720 average monthly searches; use a supporting page instead of repeatedly changing an existing page." - }, - { - "action": "create_supporting_content", - "evidence": { - "avg_monthly_searches": 210, - "business_value": "high", - "gsc_impressions": 0, - "intent": "informational", - "source": "keyword-planner", - "source_page": "https://1cli.dev/en/" - }, - "format": "blog", - "id": "dp_605ec08e7fc7", - "priority": "medium", - "query": "monorepo example", - "success_metric": "GSC starts showing impressions for this query/page within 28-90 days.", - "suggested_path": "/en/blog/monorepo-example", - "title": "Monorepo Example", - "type": "content_idea", - "why_now": "Keyword Planner shows 210 average monthly searches; use a supporting page instead of repeatedly changing an existing page." - } - ], - "verify_now": [ - { - "action": "verify_recent_technical_change", - "commands": [ - "seo verify --page 'https://1cli.dev/en/' -o json" - ], - "days_remaining": 28, - "id": "dp_64738be983f0", - "page": "https://1cli.dev/en/", - "priority": "high", - "query": "one cli", - "success_metric": "The live page reports the expected canonical, redirect, sitemap, or indexability state.", - "type": "verification", - "why_now": "A recent technical SEO change should be verified before making more changes." - }, - { - "action": "verify_recent_technical_change", - "commands": [ - "seo verify --page 'https://www.1cli.dev/en/tutorials/container-build-push' -o json" - ], - "days_remaining": 28, - "id": "dp_d7af2022702c", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "priority": "high", - "query": "push image", - "success_metric": "The live page reports the expected canonical, redirect, sitemap, or indexability state.", - "type": "verification", - "why_now": "A recent technical SEO change should be verified before making more changes." - }, - { - "action": "verify_recent_technical_change", - "commands": [ - "seo verify --page 'https://1cli.dev/en/' -o json" - ], - "days_remaining": 28, - "id": "dp_c450082a5af7", - "page": "https://1cli.dev/en/", - "priority": "high", - "query": "one-cli", - "success_metric": "The live page reports the expected canonical, redirect, sitemap, or indexability state.", - "type": "verification", - "why_now": "A recent technical SEO change should be verified before making more changes." - } - ], - "wait_review_later": [ - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "fixed homepage canonical/title/meta", - "experiment_id": "exp_20260529_2aea336c95", - "started_at": "2026-05-29T08:34:08Z" - }, - "id": "dp_c894de7e7fc2", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "one cli", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "A recent SEO change is still inside its 28-day measurement window." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "added www/non-www and legacy path redirects in root and docs Vercel configs", - "experiment_id": "exp_20260529_ff52e43ac5", - "started_at": "2026-05-29T08:44:33Z" - }, - "id": "dp_cc0cbb10224d", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "priority": "medium", - "query": "push image", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "A recent SEO change is still inside its 28-day measurement window." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "homepage canonical/title/meta also covers one-cli brand variant", - "experiment_id": "exp_20260529_fa09a7c30b", - "started_at": "2026-05-29T08:45:14Z" - }, - "id": "dp_8c199897de78", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "one-cli", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "A recent SEO change is still inside its 28-day measurement window." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "fixed homepage canonical/title/meta", - "experiment_id": "exp_20260529_2aea336c95", - "started_at": "2026-05-29T08:34:08Z", - "suppressed_rule_id": "gsc.low_ctr.v1" - }, - "id": "dp_3b2b551292e5", - "page": "https://1cli.dev/en/", - "priority": "high", - "query": "one cli", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "This opportunity matches a recent SEO change that is still being measured; avoid changing the same page again too soon." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "fixed homepage canonical/title/meta", - "experiment_id": "exp_20260529_2aea336c95", - "started_at": "2026-05-29T08:34:08Z", - "suppressed_rule_id": "url.canonical_split.v1" - }, - "id": "dp_d4cc32e8e873", - "page": "https://1cli.dev/en/", - "priority": "high", - "query": "one cli", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "This opportunity matches a recent SEO change that is still being measured; avoid changing the same page again too soon." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "added www/non-www and legacy path redirects in root and docs Vercel configs", - "experiment_id": "exp_20260529_ff52e43ac5", - "started_at": "2026-05-29T08:44:33Z", - "suppressed_rule_id": "url.canonical_split.v1" - }, - "id": "dp_d5ccea39718e", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "priority": "high", - "query": "push image", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "This opportunity matches a recent SEO change that is still being measured; avoid changing the same page again too soon." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "added www/non-www and legacy path redirects in root and docs Vercel configs", - "experiment_id": "exp_20260529_ff52e43ac5", - "started_at": "2026-05-29T08:44:33Z", - "suppressed_rule_id": "gsc.cannibalization.v1" - }, - "id": "dp_c2a8a3aecd97", - "page": "https://www.1cli.dev/en/tutorials/container-build-push", - "priority": "medium", - "query": "push image", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "This opportunity matches a recent SEO change that is still being measured; avoid changing the same page again too soon." - }, - { - "action": "wait_for_gsc_measurement", - "days_remaining": 28, - "evidence": { - "change_summary": "fixed homepage canonical/title/meta", - "experiment_id": "exp_20260529_2aea336c95", - "started_at": "2026-05-29T08:34:08Z", - "suppressed_rule_id": "gsc.cannibalization.v1" - }, - "id": "dp_bb45016f8204", - "page": "https://1cli.dev/en/", - "priority": "medium", - "query": "one cli", - "success_metric": "Review impressions, clicks, CTR, and average position after the window ends.", - "type": "measurement_wait", - "why_now": "This opportunity matches a recent SEO change that is still being measured; avoid changing the same page again too soon." - } - ] - }, - "gsc_rows": 31, - "high_priority_opportunities": 3, - "impressions_90d": 44, - "opportunities_count": 12, - "stage": "no_data" - } - }, - { - "id": "report_20260527", - "type": "weekly", - "period_start": "2026-05-21", - "period_end": "2026-05-27", - "created_at": "2026-05-29T07:08:27.874822Z", - "summary": { - "average_position_delta": -10.080459770114944, - "clicks_delta": -1, - "ctr_delta": -0.06666666666666667, - "current": { - "average_position": 8.586206896551724, - "clicks": 0, - "ctr": 0, - "impressions": 29 - }, - "impressions_delta": 14, - "previous": { - "average_position": 18.666666666666668, - "clicks": 1, - "ctr": 0.06666666666666667, - "impressions": 15 - } - } - } - ] -} From 2f9294d546079018d6aab0b5325becca72624eae Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Mon, 28 Sep 2026 23:30:29 +0800 Subject: [PATCH 10/16] refactor: remove dead code left by workspace and dashboard refactors --- apps/dashboard/package.json | 1 - .../src/components/ErrorBoundary.tsx | 17 - apps/dashboard/src/components/ui/checkbox.tsx | 26 -- .../src/components/ui/scroll-area.tsx | 53 --- .../src/components/ui/toggle-group.tsx | 75 ---- apps/dashboard/src/components/ui/toggle.tsx | 43 --- .../manifest-draft/manifest-draft-store.ts | 8 +- .../project-settings/ProjectInspector.tsx | 3 +- .../project-settings/ProjectMatrix.tsx | 334 ------------------ .../project-settings/forms/FormLayout.tsx | 10 - .../project-settings/forms/helpers.ts | 51 --- .../WorkspaceActionCenter.tsx | 157 -------- apps/dashboard/src/lib/stores/toast.ts | 224 ------------ apps/dashboard/src/lib/toast.ts | 4 - apps/dashboard/src/lib/utils.ts | 27 -- apps/dashboard/src/locales/en-US.json | 284 +-------------- apps/dashboard/src/locales/zh-CN.json | 284 +-------------- apps/dashboard/src/router/SettingsDialog.tsx | 30 -- .../internal/adapters/env/infisical/client.go | 6 - .../internal/adapters/env/infisical/crud.go | 20 -- .../internal/adapters/env/infisical/init.go | 22 -- .../cli/internal/adapters/shared/helpers.go | 24 -- .../application/manifest/service_test.go | 7 +- packages/cli/internal/core/backend/catalog.go | 24 -- packages/cli/internal/core/backend/types.go | 17 - .../cli/internal/core/workspace/backend.go | 21 -- .../internal/core/workspace/backend_apply.go | 35 -- .../internal/core/workspace/backend_test.go | 26 -- .../internal/core/workspace/build_version.go | 22 -- .../core/workspace/environment_compat.go | 29 -- .../core/workspace/environment_compat_test.go | 37 -- .../workspace/environment_defaults_test.go | 13 + packages/cli/internal/core/workspace/infra.go | 42 --- .../cli/internal/core/workspace/json_order.go | 111 ------ .../cli/internal/core/workspace/manifest.go | 5 - .../core/workspace/manifest_roundtrip_test.go | 151 ++------ .../internal/core/workspace/manifest_test.go | 61 ---- .../manifest_workspace_identity_test.go | 24 +- .../internal/core/workspace/manifest_write.go | 178 +--------- .../cli/internal/core/workspace/overview.go | 7 - .../internal/core/workspace/project_cwd.go | 132 ------- .../cli/internal/core/workspace/projects.go | 188 +--------- packages/cli/internal/core/workspace/roots.go | 35 +- .../internal/modules/creation/artifacts.go | 9 - .../cli/internal/modules/creation/preset.go | 6 +- .../internal/modules/dependencies/service.go | 2 - packages/cli/internal/modules/preset/codes.go | 3 - .../internal/platform/i18n/locales/en-US.json | 29 -- .../internal/platform/i18n/locales/zh-CN.json | 29 -- packages/cli/internal/platform/output/mode.go | 3 - .../cli/internal/platform/process/external.go | 32 -- .../cli/internal/platform/prompt/prompt.go | 26 -- .../cli/internal/transport/cobra/env/set.go | 9 - .../http/handlers_workspace_mutate.go | 5 - packages/cli/tests/e2e/e2e_helpers_test.go | 7 - .../tests/e2e/snapshot_e2e_tty_unix_test.go | 27 -- pnpm-lock.yaml | 9 - 57 files changed, 96 insertions(+), 2968 deletions(-) delete mode 100644 apps/dashboard/src/components/ui/checkbox.tsx delete mode 100644 apps/dashboard/src/components/ui/scroll-area.tsx delete mode 100644 apps/dashboard/src/components/ui/toggle-group.tsx delete mode 100644 apps/dashboard/src/components/ui/toggle.tsx delete mode 100644 apps/dashboard/src/features/project-settings/ProjectMatrix.tsx delete mode 100644 apps/dashboard/src/features/project-settings/forms/helpers.ts delete mode 100644 apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx delete mode 100644 apps/dashboard/src/lib/stores/toast.ts delete mode 100644 apps/dashboard/src/router/SettingsDialog.tsx delete mode 100644 packages/cli/internal/adapters/shared/helpers.go delete mode 100644 packages/cli/internal/core/workspace/backend_apply.go delete mode 100644 packages/cli/internal/core/workspace/backend_test.go delete mode 100644 packages/cli/internal/core/workspace/environment_compat.go delete mode 100644 packages/cli/internal/core/workspace/environment_compat_test.go create mode 100644 packages/cli/internal/core/workspace/environment_defaults_test.go delete mode 100644 packages/cli/internal/core/workspace/infra.go delete mode 100644 packages/cli/internal/core/workspace/json_order.go delete mode 100644 packages/cli/internal/core/workspace/project_cwd.go delete mode 100644 packages/cli/internal/platform/process/external.go diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 2fd4745..1ec7e14 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -43,7 +43,6 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "globals": "^17.12.0", "jsdom": "^30.1.1", "msw": "^2.15.0", "oxfmt": "^0.70.0", diff --git a/apps/dashboard/src/components/ErrorBoundary.tsx b/apps/dashboard/src/components/ErrorBoundary.tsx index d86358f..119134f 100644 --- a/apps/dashboard/src/components/ErrorBoundary.tsx +++ b/apps/dashboard/src/components/ErrorBoundary.tsx @@ -140,20 +140,3 @@ const DefaultErrorFallback: React.FC<{ export const ErrorBoundary: React.FC = ({ children, ...props }) => { return {children}; }; - -// HOC 版本的 ErrorBoundary -export const withErrorBoundary =

( - Component: React.ComponentType

, - fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>, -) => { - const WrappedComponent = (props: P) => ( - - - - ); - - WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`; - return WrappedComponent; -}; - -export default ErrorBoundary; diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx deleted file mode 100644 index 471b84a..0000000 --- a/apps/dashboard/src/components/ui/checkbox.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { Checkbox as CheckboxPrimitive } from "radix-ui"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -function Checkbox({ className, ...props }: React.ComponentProps) { - return ( - - - - - - ); -} - -export { Checkbox }; diff --git a/apps/dashboard/src/components/ui/scroll-area.tsx b/apps/dashboard/src/components/ui/scroll-area.tsx deleted file mode 100644 index 8bbf5b8..0000000 --- a/apps/dashboard/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -function ScrollArea({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ); -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -export { ScrollArea, ScrollBar }; diff --git a/apps/dashboard/src/components/ui/toggle-group.tsx b/apps/dashboard/src/components/ui/toggle-group.tsx deleted file mode 100644 index 46f8582..0000000 --- a/apps/dashboard/src/components/ui/toggle-group.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { VariantProps } from "class-variance-authority"; -import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"; -import * as React from "react"; -import { toggleVariants } from "@/components/ui/toggle"; -import { cn } from "@/lib/utils"; - -const ToggleGroupContext = React.createContext< - VariantProps & { spacing?: number } ->({ - size: "default", - variant: "default", - spacing: 0, -}); - -function ToggleGroup({ - className, - variant, - size, - spacing = 0, - children, - ...props -}: React.ComponentProps & - VariantProps & { spacing?: number }) { - return ( - - - {children} - - - ); -} - -function ToggleGroupItem({ - className, - children, - variant, - size, - ...props -}: React.ComponentProps & VariantProps) { - const context = React.useContext(ToggleGroupContext); - - return ( - - {children} - - ); -} - -export { ToggleGroup, ToggleGroupItem }; diff --git a/apps/dashboard/src/components/ui/toggle.tsx b/apps/dashboard/src/components/ui/toggle.tsx deleted file mode 100644 index cd3b10a..0000000 --- a/apps/dashboard/src/components/ui/toggle.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import { Toggle as TogglePrimitive } from "radix-ui"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", - }, - size: { - default: "h-9 min-w-9 px-2", - sm: "h-8 min-w-8 px-1.5", - lg: "h-10 min-w-10 px-2.5", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -function Toggle({ - className, - variant, - size, - ...props -}: React.ComponentProps & VariantProps) { - return ( - - ); -} - -export { Toggle, toggleVariants }; diff --git a/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts b/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts index 19b6fd3..0445211 100644 --- a/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts +++ b/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts @@ -1,5 +1,5 @@ import { createStore } from "@/lib/utils"; -import type { ProjectManifestPatch, JsonValue, WorkspaceManifestPatch } from "@/types/api"; +import type { ProjectManifestPatch, WorkspaceManifestPatch } from "@/types/api"; export type ManifestDraftSection = "general" | "environment"; @@ -212,9 +212,3 @@ export const useManifestDraftStore = createStore( }), "manifestDraftStore", ); - -export function displayDraftValue(value: DraftValue): string { - if (value === undefined || value === null || value === "") return "—"; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value satisfies JsonValue); -} diff --git a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx index 187a8a2..62f1ac3 100644 --- a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx @@ -49,11 +49,12 @@ import { useEnvironmentDirtyStore } from "@/features/environment-context/environ import { EnvironmentForm } from "@/features/project-settings/forms/EnvironmentForm"; import { ServicePanel } from "@/features/services/ServicePanel"; import { GeneralForm } from "@/features/project-settings/forms/GeneralForm"; -import type { ProjectInspectorTab } from "@/features/project-settings/ProjectMatrix"; import { WorkspaceSettingsDialog } from "@/features/workspace-settings/WorkspaceSettingsDialog"; import { cn } from "@/lib/utils"; import type { OverviewProject, ProjectSettingsResponse } from "@/types/api"; +type ProjectInspectorTab = "overview" | "environment" | "runtime"; + interface ProjectInspectorProps { projects: OverviewProject[]; currentBackend?: string; diff --git a/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx b/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx deleted file mode 100644 index 7fd28ad..0000000 --- a/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { - AlertCircle, - Boxes, - CheckCircle2, - Code2, - KeyRound, - Library, - Search, - SlidersHorizontal, -} from "lucide-react"; -import type React from "react"; -import { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; -import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { cn } from "@/lib/utils"; -import type { - OverviewIssue, - OverviewIssueDomain, - OverviewProject, - OverviewProjectKind, -} from "@/types/api"; - -export type ProjectInspectorTab = "overview" | "environment" | "runtime"; - -type MatrixDomain = OverviewIssueDomain; - -interface ProjectMatrixProps { - projects: OverviewProject[]; - workspaceEnvironment?: string; - readOnly?: boolean; - onInspect(project: OverviewProject, tab: ProjectInspectorTab): void; -} - -const KIND_ICON: Record> = { - app: Code2, - service: Boxes, - package: Library, -}; - -function issueFor( - project: OverviewProject, - domain: OverviewIssueDomain, -): OverviewIssue | undefined { - return project.issues?.find((issue) => issue.domain === domain); -} - -function projectSearchText(project: OverviewProject): string { - return [ - project.name, - project.relativeDir, - project.kind, - project.templateId, - project.toolchain, - ...Object.values(project.domains ?? {}), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); -} - -interface DomainCellProps { - project: OverviewProject; - domain: MatrixDomain; - backend?: string; - readOnly?: boolean; - onClick(): void; -} - -const DOMAIN_ICON: Record> = { - env: KeyRound, -}; - -const DomainCell: React.FC = ({ project, domain, backend, readOnly, onClick }) => { - const { t } = useTranslation(); - const issue = issueFor(project, domain); - const Icon = DOMAIN_ICON[domain]; - - return ( - - ); -}; - -export const ProjectMatrix: React.FC = ({ - projects, - workspaceEnvironment, - readOnly, - onInspect, -}) => { - const { t } = useTranslation(); - const [query, setQuery] = useState(""); - const [kind, setKind] = useState<"all" | OverviewProjectKind>("all"); - const [status, setStatus] = useState<"all" | "attention" | "healthy">("all"); - const [filtersOpen, setFiltersOpen] = useState(false); - const filtered = useMemo(() => { - const normalized = query.trim().toLowerCase(); - return projects - .filter((project) => { - const hasIssues = (project.issues?.length ?? 0) > 0; - return ( - (kind === "all" || project.kind === kind) && - (status === "all" || (status === "attention" ? hasIssues : !hasIssues)) && - (!normalized || projectSearchText(project).includes(normalized)) - ); - }) - .sort( - (left, right) => - Number((right.issues?.length ?? 0) > 0) - Number((left.issues?.length ?? 0) > 0), - ); - }, [kind, projects, query, status]); - - return ( - -

-
-
-

- {t("projects.title")} -

- - {projects.length} - -
-

{t("projects.description")}

-
- -
- - {filtersOpen ? ( -
-
- -
- - - - - setQuery(event.target.value)} - placeholder={t("projects.search")} - aria-label={t("projects.search")} - className="text-xs" - /> - -
- -
-
- ) : null} - - {filtered.length === 0 ? ( - - - - - - {t("projects.empty.title")} - {t("projects.empty.description")} - - - ) : ( - - - - {t("projects.matrix.project")} - {t("projects.matrix.environment")} - - {t("projects.matrix.status")} - - - - - {filtered.map((project) => { - const Icon = KIND_ICON[project.kind]; - const issueCount = project.issues?.length ?? 0; - return ( - - - - - - onInspect(project, "environment")} - /> - - - {issueCount > 0 ? ( - - ) : ( - - - {t("projects.matrix.healthy")} - - )} - - - ); - })} - -
- )} - - ); -}; diff --git a/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx b/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx index 206b92d..c7986c8 100644 --- a/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx +++ b/apps/dashboard/src/features/project-settings/forms/FormLayout.tsx @@ -18,16 +18,6 @@ export const ManifestDraftLayout: React.FC = ({ childre
{children}
); -export const FormLayout: React.FC< - React.PropsWithChildren<{ - title: string; - }> -> = ({ title, children }) => ( -
- {children} -
-); - export const ProjectField: React.FC< React.PropsWithChildren<{ label: string; htmlFor: string }> > = ({ label, htmlFor, children }) => ( diff --git a/apps/dashboard/src/features/project-settings/forms/helpers.ts b/apps/dashboard/src/features/project-settings/forms/helpers.ts deleted file mode 100644 index 9b8a494..0000000 --- a/apps/dashboard/src/features/project-settings/forms/helpers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { overviewKeyFor } from "@/api/workspace"; -import type { useToast } from "@/hooks/useToast"; -import type { JsonValue } from "@/types/api"; - -export function refreshOverview( - mutate: (key: string) => unknown, - workspaceEntryId?: string, - environment?: string, -): void { - void mutate(overviewKeyFor(workspaceEntryId, environment)); -} - -export function showSaveError( - toast: ReturnType, - title: string, - error: unknown, -): void { - const failure = error as { code?: string; message?: string }; - toast.error(title, { description: failure.message ?? String(error) }); -} - -export function configPathValue( - config: Record, - path: string, -): JsonValue | undefined { - let value: JsonValue | undefined = config; - for (const segment of path.split("/").filter(Boolean)) { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - value = (value as Record)[segment]; - } - return value; -} - -export function setConfigPathValue( - config: Record, - path: string, - nextValue: string, -): Record { - const result = structuredClone(config); - const segments = path.split("/").filter(Boolean); - let current: Record = result; - for (const segment of segments.slice(0, -1)) { - const existing = current[segment]; - if (!existing || typeof existing !== "object" || Array.isArray(existing)) { - current[segment] = {}; - } - current = current[segment] as Record; - } - current[segments.at(-1) ?? path] = nextValue; - return result; -} diff --git a/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx b/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx deleted file mode 100644 index b6d7e74..0000000 --- a/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { AlertCircle, ArrowRight, CheckCircle2, FolderCog } from "lucide-react"; -import type React from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import type { ProjectInspectorTab } from "@/features/project-settings/ProjectMatrix"; -import type { OverviewIssue, OverviewProject } from "@/types/api"; - -interface WorkspaceActionCenterProps { - projects: OverviewProject[]; - workspaceIssues: OverviewIssue[]; - readOnly?: boolean; - issueMessage(issue: OverviewIssue): string; - onInspect(project: OverviewProject, tab: ProjectInspectorTab): void; -} - -function issueKey(issue: OverviewIssue): string { - return [issue.domain, issue.reason ?? "backend", issue.backend ?? "", issue.profile ?? ""].join( - ":", - ); -} - -function profileIssueSettingsPath(issue: OverviewIssue): string { - const section = issue.section ?? (issue.backend ? `${issue.domain}/${issue.backend}` : ""); - const [domain, backend, extra] = section.split("/"); - if (!domain || !backend || extra) return "/settings"; - return `/settings/${encodeURIComponent(domain)}/${encodeURIComponent(backend)}`; -} - -export const WorkspaceActionCenter: React.FC = ({ - projects, - workspaceIssues, - readOnly, - issueMessage, - onInspect, -}) => { - const { t } = useTranslation(); - function jumpToSection(event: React.MouseEvent, href: string) { - event.preventDefault(); - document.querySelector(href)?.scrollIntoView({ behavior: "smooth", block: "start" }); - window.history.replaceState(null, "", href); - } - const projectActions = projects.flatMap((project) => - (project.issues ?? []).map((issue) => ({ project, issue })), - ); - const total = workspaceIssues.length + projectActions.length; - - return ( - -
-
-

- {total > 0 ? t("overview.actionCenter.title") : t("overview.actionCenter.allGood")} -

-

- {total > 0 - ? t("overview.actionCenter.hint") - : t("overview.actionCenter.allGoodDescription")} -

-
- - {String(total).padStart(2, "0")} - -
- - {total === 0 ? ( -
-
-
- -
-

- {t("overview.actionCenter.clearDescription")} -

-
-
- ) : ( - <> -
- {workspaceIssues.map((issue) => ( -
- - - -
-

- {t("overview.actionCenter.workspaceLabel")} -

-

{issueMessage(issue)}

-
- -
- ))} - - {projectActions.map(({ project, issue }) => ( -
- - - -
-

- {project.name} - {project.relativeDir} -

-

{issueMessage(issue)}

-
- -
- ))} -
- jumpToSection(event, "#workspace-projects")} - className="flex h-9 items-center justify-between border-t border-border bg-muted/25 px-3 font-mono text-[9px] uppercase tracking-wide text-primary hover:bg-muted/45" - > - {t("overview.actionCenter.viewProjects")} - - - - )} -
- ); -}; diff --git a/apps/dashboard/src/lib/stores/toast.ts b/apps/dashboard/src/lib/stores/toast.ts deleted file mode 100644 index 82723d6..0000000 --- a/apps/dashboard/src/lib/stores/toast.ts +++ /dev/null @@ -1,224 +0,0 @@ -import Toast, { type ToastOptions, type ToastType } from "../toast"; -import { createStore } from "../utils"; - -// Toast历史记录接口 -export interface ToastHistoryItem { - id: string; - type: ToastType; - content: string; - timestamp: number; - options?: ToastOptions; -} - -// Toast状态接口 -interface ToastState { - /** Toast历史记录 */ - history: ToastHistoryItem[]; - /** 当前活跃的Toast数量 */ - activeCount: number; - /** 是否启用Toast历史记录 */ - enableHistory: boolean; - /** 最大历史记录数量 */ - maxHistoryCount: number; - - // 显示方法 - success: (content: string, options?: ToastOptions) => string; - info: (content: string, options?: ToastOptions) => string; - warning: (content: string, options?: ToastOptions) => string; - error: (content: string, options?: ToastOptions) => string; - loading: (content: string, options?: ToastOptions) => string; - - // 便捷方法 - successAction: (action: string, options?: ToastOptions) => string; - errorAction: (action: string, reason?: string, options?: ToastOptions) => string; - loadingAction: (action: string, options?: ToastOptions) => string; - copySuccess: (options?: ToastOptions) => string; - networkError: (options?: ToastOptions) => string; - permissionDenied: (options?: ToastOptions) => string; - - // 管理方法 - update: ( - key: string, - content: string, - type?: ToastType, - options?: Omit, - ) => void; - destroy: (key?: string) => void; - clearHistory: () => void; - setEnableHistory: (enable: boolean) => void; - setMaxHistoryCount: (count: number) => void; - getHistoryByType: (type: ToastType) => ToastHistoryItem[]; - getTodayHistory: () => ToastHistoryItem[]; -} - -// 生成唯一ID -const generateId = () => Math.random().toString(36).substring(2, 15); - -// 添加到历史记录 -const addToHistory = ( - get: () => ToastState, - set: (partial: Partial) => void, - type: ToastType, - content: string, - options?: ToastOptions, -) => { - const state = get(); - if (!state.enableHistory) return; - - const historyItem: ToastHistoryItem = { - id: generateId(), - type, - content, - timestamp: Date.now(), - options, - }; - - const newHistory = [historyItem, ...state.history].slice(0, state.maxHistoryCount); - - set({ history: newHistory }); -}; - -/** - * Toast状态管理Store - */ -export const useToastStore = createStore( - (set, get) => ({ - history: [], - activeCount: 0, - enableHistory: true, - maxHistoryCount: 100, - - success: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "success", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.success(content, finalOptions); - return id; - }, - - info: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "info", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.info(content, finalOptions); - return id; - }, - - warning: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "warning", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.warning(content, finalOptions); - return id; - }, - - error: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "error", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.error(content, finalOptions); - return id; - }, - - loading: (content: string, options?: ToastOptions) => { - const id = options?.key || generateId(); - const finalOptions = { ...options, key: id }; - - addToHistory(get, set, "loading", content, finalOptions); - set({ activeCount: get().activeCount + 1 }); - - Toast.loading(content, finalOptions); - return id; - }, - - successAction: (action: string, options?: ToastOptions) => { - return get().success(`${action}成功!`, options); - }, - - errorAction: (action: string, reason?: string, options?: ToastOptions) => { - const content = reason ? `${action}失败:${reason}` : `${action}失败!`; - return get().error(content, options); - }, - - loadingAction: (action: string, options?: ToastOptions) => { - return get().loading(`${action}中...`, options); - }, - - copySuccess: (options?: ToastOptions) => { - return get().success("复制成功!", { duration: 2, ...options }); - }, - - networkError: (options?: ToastOptions) => { - return get().error("网络连接失败,请检查网络设置", { - duration: 5, - ...options, - }); - }, - - permissionDenied: (options?: ToastOptions) => { - return get().warning("权限不足,请联系管理员", { - duration: 4, - ...options, - }); - }, - - update: ( - key: string, - content: string, - type: ToastType = "info", - options?: Omit, - ) => { - Toast.update(key, content, type, options); - }, - - destroy: (key?: string) => { - if (key) { - set({ activeCount: Math.max(0, get().activeCount - 1) }); - } else { - set({ activeCount: 0 }); - } - Toast.destroy(key); - }, - - clearHistory: () => { - set({ history: [] }); - }, - - setEnableHistory: (enable: boolean) => { - set({ enableHistory: enable }); - }, - - setMaxHistoryCount: (count: number) => { - set({ maxHistoryCount: Math.max(1, count) }); - const { history } = get(); - if (history.length > count) { - set({ history: history.slice(0, count) }); - } - }, - - getHistoryByType: (type: ToastType) => { - return get().history.filter((item) => item.type === type); - }, - - getTodayHistory: () => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayTimestamp = today.getTime(); - - return get().history.filter((item) => item.timestamp >= todayTimestamp); - }, - }), - "toastStore", -); diff --git a/apps/dashboard/src/lib/toast.ts b/apps/dashboard/src/lib/toast.ts index 7809dd6..4620be9 100644 --- a/apps/dashboard/src/lib/toast.ts +++ b/apps/dashboard/src/lib/toast.ts @@ -216,7 +216,3 @@ const Toast = { }; export default Toast; - -// 导出常用方法的简写 -export const toast = Toast; -export const { success, info, warning, error, loading } = Toast; diff --git a/apps/dashboard/src/lib/utils.ts b/apps/dashboard/src/lib/utils.ts index 6406329..e2c2266 100644 --- a/apps/dashboard/src/lib/utils.ts +++ b/apps/dashboard/src/lib/utils.ts @@ -11,33 +11,6 @@ export const createStore = (storeCreator: StateCreator, name: string) => { return create(storeCreator); }; -export const formatDate = (date: string | Date) => { - return new Date(date).toLocaleString("zh-CN"); -}; - -export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export const debounce = void>(func: T, wait: number): T => { - let timeout: NodeJS.Timeout; - return ((...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }) as T; -}; - -export const throttle = void>(func: T, limit: number): T => { - let inThrottle: boolean; - return ((...args) => { - if (!inThrottle) { - func(...args); - inThrottle = true; - setTimeout(() => { - inThrottle = false; - }, limit); - } - }) as T; -}; - export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index 9520735..0d30ff9 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -1,6 +1,5 @@ { "sidebar": { - "brand": "local control", "home": "Home", "settings": "Settings", "themeToDark": "Switch to dark mode", @@ -8,25 +7,16 @@ "language": "Language", "languageAuto": "Follow system", "languageZh": "中文", - "languageEn": "English", - "navigation": "Navigation", - "openNavigation": "Open navigation", - "closeNavigation": "Close navigation" + "languageEn": "English" }, "topbar": { - "home": "Workbench", "workspaces": "Home", "settings": "Settings", "profile": "Credential profiles", - "sectionsRoot": "Sections", - "devData": { - "fixtureLiveProfiles": "Fixture Workspace · Live local Profiles" - } + "sectionsRoot": "Sections" }, "manifestDraft": { "saveButton": "Save changes · {{count}}", - "workspaceScope": "Workspace", - "changeCount": "{{count}} changed", "title": "Write these changes to the manifest?", "description": "This updates one.manifest.json in the Workspace. Review the exact changes before continuing.", "currentManifest": "Current manifest", @@ -60,19 +50,12 @@ "loadFailedTitle": "Could not load Workspaces", "loadFailedDescription": "The local Workspace registry could not be read.", "retry": "Retry", - "registryCount": "{{count}} registered Workspaces", "projectCount": "{{count}} Projects", - "currentSession": "This one serve session", - "workspaceId": "Workspace ID", - "idUnavailable": "Not available", "projects": "Projects", - "projectCountUnavailable": "Unavailable", - "projectCountShort": "{{count}} projects", "lastDetected": "Last detected", "emptyTitle": "No Workspaces yet", "emptyDescription": "Run one create to create a Workspace, or run one serve inside an existing Workspace to register it.", "listLabel": "Registered Workspaces", - "registeredOnMachine": "Registered on this machine", "registrationHint": "New Workspaces appear here after you run one serve.", "refresh": "Refresh", "search": "Search name, path or ID…", @@ -121,7 +104,6 @@ } }, "forget": { - "short": "Remove Workspace", "action": "Remove {{name}}", "confirm": "Remove Workspace \"{{name}}\"? This only removes the local registry entry; no project files or remote variables will be deleted.", "done": "Removed {{name}}", @@ -140,14 +122,9 @@ "title": "Workspace not found", "description": "This registry entry no longer exists. Return home to choose another workspace.", "back": "Choose a Workspace" - }, - "noneReady": { - "title": "No available Workspaces", - "description": "Select an unavailable Workspace in the left rail for repair guidance, or run one serve inside a valid Workspace." } }, "notFound": { - "message": "404 - Page not found", "title": "Page not found", "description": "This address does not match a dashboard page. Return home to continue." }, @@ -159,12 +136,6 @@ "reload": "Reload", "home": "Back home" }, - "settings": { - "title": "Settings", - "description": "Manage the machine-level credential profiles available to your Workspaces and Projects.", - "loadFailedTitle": "Failed to load Settings", - "manageBackend": "Manage Profiles" - }, "sections": { "groupLabel": { "env": "Environment variables" @@ -179,147 +150,27 @@ "home": { "title": "Machine-level profiles", "loadFailedTitle": "Failed to load profiles", - "profileCount_one": "{{count}} profile", - "profileCount_other": "{{count}} profiles", - "default": "default: {{name}}", - "noDefault": "no default", "loading": "Loading…" }, - "detail": { - "unknownSectionTitle": "Unknown section", - "unknownSectionBody": "{{domain}}/{{backend}} is not a valid (domain, backend) pair.", - "loadFailedTitle": "Load failed", - "showSecrets": "Show secrets", - "hideSecrets": "Hide secrets", - "showingSecrets": "Showing secrets", - "addProfile": "New profile", - "loading": "Loading…", - "empty": "No profiles yet. Click 'New profile' to create the first one.", - "default": "default", - "tableProfile": "Profile", - "tableSummary": "Summary", - "tableActions": "Actions", - "setDefault": "Set default", - "edit": "Edit", - "remove": "Delete", - "confirmRemove": "Delete profile \"{{name}}\"? This cannot be undone." - }, "form": { - "addTitle": "New profile", - "editTitle": "Edit {{name}}", - "addDescription": "Fill in and save. The first profile in a section is set default automatically.", - "editDescription": "Saving overwrites existing values; leave secret fields blank to keep them unchanged.", - "profileName": "Profile name", - "profileNamePlaceholder": "e.g. work / prod / acr-prod", - "setDefaultAfterSave": "Set default after save", - "setDefaultAfterSaveAuto": "Set default after save (this section has no default yet, so it will be set automatically)", "cancel": "Cancel", - "save": "Save", - "fields": { - "siteUrl": "Site URL", - "clientId": "Client ID", - "clientSecret": "Client Secret", - "secretUnchangedPlaceholder": "Leave blank to keep unchanged", - "endpoint": "Endpoint (leave blank for AWS S3 to use SDK defaults)", - "endpointPlaceholder": "https://", - "region": "Region", - "regionPlaceholder": "us-east-1 / cn-hangzhou / auto", - "forcePathStyle": "force path style (MinIO / RustFS need this)", - "accessKeyId": "Access Key ID", - "accessKeySecret": "Access Key Secret", - "kubeconfigPath": "Kubeconfig path", - "kubeconfigContext": "Context name", - "kubeconfigContextPlaceholder": "prod / staging", - "teamSlug": "Team slug (leave blank for personal scope)", - "teamSlugPlaceholder": "my-org", - "apiToken": "API Token", - "accountId": "Account ID (required for multi-account tokens, otherwise leave blank)", - "accountIdPlaceholder": "a1b2c3...", - "regionEdgeOne": "Region (e.g. ap-guangzhou; optional)", - "regionEdgeOnePlaceholder": "ap-guangzhou", - "registry": "Registry", - "registryPlaceholder": "your-harbor.example.com / quay.io / ...", - "acrRegion": "ACR Region", - "acrRegionPlaceholder": "cn-hangzhou / cn-shanghai (host derives to registry..aliyuncs.com)", - "namespace": "Namespace", - "namespacePlaceholder": "org / username", - "username": "Username", - "password": "Password / token" - }, - "summary": { - "noFields": "(no fields)", - "site": "site: {{site}}", - "notSet": "(not set)", - "endpointDefault": "(SDK default)", - "regionDefault": "-", - "endpointRegion": "endpoint: {{endpoint}} · region: {{region}}", - "contextDefault": "-", - "kubeconfigDefault": "(default)", - "contextFile": "context: {{context}} · file: {{file}}", - "team": "team: {{team}}", - "teamPersonal": "(personal)", - "account": "account: {{account}}", - "accountTokenDefault": "(token default)", - "regionLabel": "region: {{region}}", - "regionDefaultLabel": "(default)", - "registryUnset": "(no registry)", - "acrRegionUnset": "(no region)" - }, "close": "Close", "discardTitle": "Discard unsaved changes?", "discardDescription": "Your changes have not been saved. Discard them to close this editor.", "continueEditing": "Keep editing" }, - "toast": { - "updated": "Updated {{name}}", - "created": "Created {{name}}", - "setDefault": "Default switched to {{name}}", - "removed": "Deleted {{name}}", - "setDefaultAfterSaveHint": "Set as default" - }, "http": { "networkError": "Network request failed. Check your connection." }, "projects": { - "title": "Project configuration", - "description": "Review and configure every project declared in the manifest from one place.", "search": "Search project, path, or backend", - "showFilters": "Show filters", - "hideFilters": "Hide filters", - "kindFilter": "Filter by project kind", - "statusFilter": "Filter by resolution status", - "kinds": { - "all": "All kinds" - }, - "statuses": { - "all": "All statuses", - "attention": "Needs attention", - "healthy": "Healthy only" - }, "empty": { - "title": "No matching projects", - "description": "Adjust the search or project-kind filter." - }, - "matrix": { - "project": "Project", - "environment": "Environment", - "status": "Status", - "notApplicable": "-", - "notApplicableTitle": "This configuration does not apply to the project", - "notConfigured": "Not configured", - "profileMissing": "Credentials required", - "setupRequired": "Setup required", - "ready": "Configured", - "healthy": "Healthy", - "issueCount": "{{count}} to resolve", - "configureDomain": "Configure {{domain}} for {{project}}" + "title": "No matching projects" } }, "projectInspector": { - "close": "Close", "workspaceTitle": "Project settings", "projectsTitle": "Projects", - "projectsDescription": "Select a project to configure", "projectListLabel": "Workspace projects", "empty": "This workspace has no projects.", "tabs": { @@ -330,27 +181,17 @@ }, "loadFailed": "Unable to load project configuration", "retry": "Retry", - "backend": "Backend", "manifestReadOnly": "Manifest · read-only", "manifestDraft": "Manifest draft", - "saved": "Project Profile binding saved", - "saveFailed": "Could not save the project Profile binding", - "values": { - "enabled": "Enabled", - "disabled": "Disabled" - }, "unsavedChangesTitle": "Discard unsaved changes?", "unsavedChanges": "You have unsaved changes. Discard them and continue?", "discardAndContinue": "Discard and continue", "general": { - "title": "Project overview", - "description": "Edit runtime project settings here. Changes remain a draft until you save them from the top bar.", "template": "Template", "toolchain": "Toolchain", "path": "Project path", "buildVersion": "Build version", "packageManager": "Package manager", - "packageManagerAutomatic": "Detect automatically", "devCommand": "Development command", "buildCommand": "Build command", "buildSource": "one build reads {{source}}. Edit that file to change the build task. Workspace dependencies and caching are managed by mise; one run build uses the same entry point.", @@ -372,7 +213,6 @@ }, "environment": { "title": "Environment configuration", - "description": "Project path, inheritance, and disable settings are saved in the manifest. Manage Infisical project binding at workspace level.", "path": "Infisical folder path", "inherits": "Inherit workspace environment", "inheritsHint": "Project variables override workspace variables with the same name.", @@ -380,31 +220,14 @@ "disabledHint": "Do not inject environment variables into project commands.", "keys": "Declared variable names" }, - "profile": { - "label": "Project profile", - "description": "Choose machine-level credentials scoped to this project and environment.", - "notRequired": "This backend does not require a credential profile.", - "loading": "Loading credential profiles…", - "loadFailed": "Could not load credential profiles.", - "automatic": "Resolve automatically (workspace / default)", - "inherited": "Inherit {{name}} ({{source}})", - "none": "No profile currently resolves", - "manage": "Manage in Settings" - }, - "draftHint": "Changes are kept in a draft. Review and save them to apply.", - "runtime": { - "title": "Service and console" - } + "draftHint": "Changes are kept in a draft. Review and save them to apply." }, "secrets": { "title": "Infisical secrets", - "description": "Manage values stored remotely for the selected environment and scope.", "unavailableTitle": "Remote secrets are not enabled", "unavailableDescription": "Bind an Infisical project in workspace settings to manage remote variables.", - "configureBackend": "Configure environment Backend", "scope": "Secret scope", "workspaceScope": "Workspace shared", - "directOnly": "Only values defined directly in this folder are editable; inherited values stay untouched.", "add": "Add secret", "key": "Key", "value": "Value", @@ -441,121 +264,26 @@ }, "project": { "fields": { - "bucket": "Bucket", - "environment": "Deployment environment", - "projectId": "Project ID", - "projectName": "Project name", - "workerName": "Worker name" + "environment": "Deployment environment" } }, "overview": { - "untitledWorkspace": "Untitled workspace", - "environments": "Environments", "projects": "Projects", - "empty": "The manifest declares no sub-projects.", - "allGood": "✓ All set", - "workspaceIssuesTitle": "Workspace-level configuration missing", - "manageProfilesCta": "Manage credential profiles", - "profileSafety": "Profiles stay in machine-local configuration; credentials never enter the manifest.", "workspaceEnv": { "title": "Workspace environment", - "scope": "Workspace scope", - "readOnly": "Read-only", "description": "Configure workspace environment storage and manage shared secrets.", - "localBinding": "Machine-local Profile binding", - "manifestLegend": "Shared manifest", - "localLegend": "This machine", "backend": "Backend", - "backendDescription": "Infisical is the workspace environment-variable source.", - "backendManifest": "Manifest setting", "backendPending": "Pending review", "backendSource": "Infisical project binding changes are reviewed before being written to one.manifest.json.", - "backendPendingHint": "This is now in Pending changes above. When saved, One CLI initializes the Backend; then you can select its machine Profile.", - "backendMissing": "Not configured", - "loading": "Loading workspace Profile binding…", - "loadFailed": "Could not load the workspace Profile binding.", - "missingBackendCli": "Choose an environment Backend before selecting a Profile.", - "saved": "Workspace Profile saved", - "saveFailed": "Could not save the workspace Profile", - "profile": { - "title": "Credential Profile", - "label": "Profile", - "description": "Choose a machine-local credential Profile for this workspace and environment.", - "localBadge": "This machine", - "waitTitle": "Apply the Backend change first", - "waitDescription": "After the target Backend is active, its Profile requirements are loaded again. This prevents saving a Profile against the wrong Backend.", - "notRequired": "This backend does not require a credential profile.", - "loading": "Loading credential profiles…", - "loadFailed": "Could not load credential profiles.", - "automatic": "Resolve automatically (machine default)", - "inherited": "Automatic · {{name}} ({{source}})", - "none": "No Profile currently resolves", - "manage": "Manage in Settings" - }, "signInHint": "Sign in to Infisical in Settings to choose a storage project.", "noProjects": "No storage projects available. Create one from Shared credentials." }, "tabs": { - "label": "Workspace sections", "environment": "Workspace environment", - "projects": "Projects", "secrets": "Infisical secrets" }, "navigation": { - "label": "Workspace page navigation", - "overview": "Attention", - "projects": "Projects", - "secrets": "Secrets", - "settings": "Workspace settings", - "attention": "{{count}} items to resolve", - "ready": "No blocking items", - "projectCount": "{{count}} projects", - "infisical": "Infisical enabled", - "notEnabled": "Remote secrets not enabled", - "backendProfile": "Backend and Profile" - }, - "actionCenter": { - "eyebrow": "Next actions", - "pending": "items to resolve", - "clear": "blocking items", - "description": "Resolve these items in order before running the corresponding One CLI commands.", - "clearDescription": "The workspace's core configuration is ready to use.", - "title": "Resolve first", - "hint": "Each item opens the exact configuration that needs attention.", - "viewProjects": "View all projects", - "workspaceLabel": "Workspace configuration", - "configureWorkspace": "Configure workspace", - "resolve": "Resolve now", - "allGood": "Nothing needs attention", - "allGoodDescription": "Continue managing secrets or inspect detailed project settings." - }, - "command": { - "ready": "ready", - "review": "review setup" - }, - "metrics": { - "projects": "Projects", - "healthy": "Healthy", - "attention": "Attention" - }, - "kinds": { - "app": "App", - "service": "Service", - "package": "Package" - }, - "issue": { - "label": { - "env": "env", - "profile": "profile" - }, - "missingProfile": "{{section}} is missing a usable credential profile{{profile}}. Fill in the API key / token.", - "missing": { - "env": "Workspace has no env backend selected. Set one before using `one env`." - } - }, - "fix": { - "profileCta": "Add credentials", - "cliHint": "Configure this Backend with One CLI." + "settings": "Workspace settings" } }, "session": { diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 6caa5db..f493d4a 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -1,6 +1,5 @@ { "sidebar": { - "brand": "local control", "home": "首页", "settings": "设置", "themeToDark": "切深色", @@ -8,25 +7,16 @@ "language": "语言", "languageAuto": "跟随系统", "languageZh": "中文", - "languageEn": "English", - "navigation": "导航", - "openNavigation": "打开导航", - "closeNavigation": "关闭导航" + "languageEn": "English" }, "topbar": { - "home": "工作台", "workspaces": "主页", "settings": "设置", "profile": "凭据 profile", - "sectionsRoot": "配置分区", - "devData": { - "fixtureLiveProfiles": "Fixture Workspace · 本机真实 Profile" - } + "sectionsRoot": "配置分区" }, "manifestDraft": { "saveButton": "保存更改 · {{count}}", - "workspaceScope": "Workspace", - "changeCount": "{{count}} 项修改", "title": "要把这些修改写入 Manifest 吗?", "description": "这会修改工作区里的 one.manifest.json。继续前请确认下面的具体差异。", "currentManifest": "当前 Manifest", @@ -60,19 +50,12 @@ "loadFailedTitle": "无法加载工作区", "loadFailedDescription": "无法读取本机工作区注册表。", "retry": "重试", - "registryCount": "{{count}} 个工作区", "projectCount": "{{count}} 个项目", - "currentSession": "本次 one serve", - "workspaceId": "Workspace ID", - "idUnavailable": "不可用", "projects": "项目", - "projectCountUnavailable": "不可用", - "projectCountShort": "{{count}} 个项目", "lastDetected": "最近识别", "emptyTitle": "尚未登记工作区", "emptyDescription": "运行 one create 创建工作区,或在已有工作区中运行 one serve 进行登记。", "listLabel": "已登记的工作区", - "registeredOnMachine": "已登记到本机", "registrationHint": "运行 one serve 后,新的 Workspace 会显示在这里。", "refresh": "刷新", "search": "搜索名称、路径或 ID…", @@ -121,7 +104,6 @@ } }, "forget": { - "short": "移除 Workspace", "action": "移除 {{name}}", "confirm": "要移除 Workspace「{{name}}」吗?这里只会移除本机注册记录,不会删除项目文件或远端变量。", "done": "已移除 {{name}}", @@ -140,14 +122,9 @@ "title": "找不到 Workspace", "description": "这条登记记录已不存在,请返回首页选择其他工作区。", "back": "选择 Workspace" - }, - "noneReady": { - "title": "没有可用的 Workspace", - "description": "可在左侧选择异常 Workspace 查看修复提示,或进入有效 Workspace 执行 one serve。" } }, "notFound": { - "message": "404 - 页面未找到", "title": "页面不存在", "description": "当前地址没有对应的管理页面,请返回首页继续。" }, @@ -159,12 +136,6 @@ "reload": "重新加载", "home": "返回首页" }, - "settings": { - "title": "设置", - "description": "管理可供 Workspace 和 Project 使用的机器级凭据 Profile。", - "loadFailedTitle": "加载设置失败", - "manageBackend": "管理 Profile" - }, "sections": { "groupLabel": { "env": "环境变量" @@ -179,147 +150,27 @@ "home": { "title": "机器级 profile", "loadFailedTitle": "加载 profile 失败", - "profileCount_one": "{{count}} 个 profile", - "profileCount_other": "{{count}} 个 profile", - "default": "default: {{name}}", - "noDefault": "无 default", "loading": "加载中…" }, - "detail": { - "unknownSectionTitle": "未知 section", - "unknownSectionBody": "{{domain}}/{{backend}} 不是合法 (domain, backend) 对。", - "loadFailedTitle": "加载失败", - "showSecrets": "显示原文", - "hideSecrets": "隐藏凭据原文", - "showingSecrets": "已显示原文", - "addProfile": "新增 profile", - "loading": "加载中…", - "empty": "还没有 profile。点击右上「新增 profile」创建第一个。", - "default": "default", - "tableProfile": "Profile", - "tableSummary": "摘要", - "tableActions": "操作", - "setDefault": "设为 default", - "edit": "编辑", - "remove": "删除", - "confirmRemove": "确认删除 profile \"{{name}}\"?此操作不可撤销。" - }, "form": { - "addTitle": "新增 profile", - "editTitle": "编辑 {{name}}", - "addDescription": "填好后点保存。第一个 profile 自动成为 default。", - "editDescription": "保存会覆盖现有内容;密钥字段留空会保持不变。", - "profileName": "profile 名", - "profileNamePlaceholder": "如 work / prod / acr-prod", - "setDefaultAfterSave": "保存后设为 default", - "setDefaultAfterSaveAuto": "保存后设为 default(当前 section 无 default,会自动设)", "cancel": "取消", - "save": "保存", - "fields": { - "siteUrl": "Site URL", - "clientId": "Client ID", - "clientSecret": "Client Secret", - "secretUnchangedPlaceholder": "留空则保持不变", - "endpoint": "Endpoint(aws-s3 可留空走 SDK 默认)", - "endpointPlaceholder": "https://", - "region": "Region", - "regionPlaceholder": "us-east-1 / cn-hangzhou / auto", - "forcePathStyle": "force path style(MinIO / RustFS 必需)", - "accessKeyId": "Access Key ID", - "accessKeySecret": "Access Key Secret", - "kubeconfigPath": "Kubeconfig 路径", - "kubeconfigContext": "Context 名", - "kubeconfigContextPlaceholder": "prod / staging", - "teamSlug": "Team slug(留空 = 个人 scope)", - "teamSlugPlaceholder": "my-org", - "apiToken": "API Token", - "accountId": "Account ID(多账号 token 必填,否则留空)", - "accountIdPlaceholder": "a1b2c3...", - "regionEdgeOne": "Region(如 ap-guangzhou,可空)", - "regionEdgeOnePlaceholder": "ap-guangzhou", - "registry": "Registry", - "registryPlaceholder": "your-harbor.example.com / quay.io / ...", - "acrRegion": "ACR Region", - "acrRegionPlaceholder": "cn-hangzhou / cn-shanghai(host 派生为 registry..aliyuncs.com)", - "namespace": "Namespace", - "namespacePlaceholder": "org / username", - "username": "Username", - "password": "Password / token" - }, - "summary": { - "noFields": "(无字段)", - "site": "site: {{site}}", - "notSet": "(未填)", - "endpointDefault": "(SDK 默认)", - "regionDefault": "-", - "endpointRegion": "endpoint: {{endpoint}} · region: {{region}}", - "contextDefault": "-", - "kubeconfigDefault": "(默认)", - "contextFile": "context: {{context}} · file: {{file}}", - "team": "team: {{team}}", - "teamPersonal": "(personal)", - "account": "account: {{account}}", - "accountTokenDefault": "(token default)", - "regionLabel": "region: {{region}}", - "regionDefaultLabel": "(default)", - "registryUnset": "(未填 registry)", - "acrRegionUnset": "(未填 region)" - }, "close": "关闭", "discardTitle": "放弃未保存的修改?", "discardDescription": "当前修改尚未保存。放弃后将关闭编辑窗口。", "continueEditing": "继续编辑" }, - "toast": { - "updated": "已更新 {{name}}", - "created": "已新增 {{name}}", - "setDefault": "default 已切换到 {{name}}", - "removed": "已删除 {{name}}", - "setDefaultAfterSaveHint": "已设为 default" - }, "http": { "networkError": "网络请求失败,请检查网络连接。" }, "projects": { - "title": "项目配置矩阵", - "description": "从一处查看并配置 manifest 中的全部项目。", "search": "搜索项目、路径或 backend", - "showFilters": "显示筛选", - "hideFilters": "收起筛选", - "kindFilter": "按项目类型筛选", - "statusFilter": "按处理状态筛选", - "kinds": { - "all": "全部类型" - }, - "statuses": { - "all": "全部状态", - "attention": "仅待处理", - "healthy": "仅正常" - }, "empty": { - "title": "没有匹配的项目", - "description": "调整搜索词或项目类型筛选。" - }, - "matrix": { - "project": "项目", - "environment": "Environment", - "status": "状态", - "notApplicable": "-", - "notApplicableTitle": "这个项目不适用该配置", - "notConfigured": "未配置", - "profileMissing": "凭据待补充", - "setupRequired": "需要配置", - "ready": "配置可用", - "healthy": "正常", - "issueCount": "{{count}} 项待处理", - "configureDomain": "为 {{project}} 配置 {{domain}}" + "title": "没有匹配的项目" } }, "projectInspector": { - "close": "关闭项目配置", "workspaceTitle": "项目设置", "projectsTitle": "项目", - "projectsDescription": "选择项目后在右侧配置", "projectListLabel": "工作区项目", "empty": "这个工作区还没有项目。", "tabs": { @@ -330,27 +181,17 @@ }, "loadFailed": "无法加载项目配置", "retry": "重试", - "backend": "Backend", "manifestReadOnly": "Manifest · 只读", "manifestDraft": "Manifest 草稿", - "saved": "项目 Profile 绑定已保存", - "saveFailed": "保存项目 Profile 绑定失败", - "values": { - "enabled": "已启用", - "disabled": "已禁用" - }, "unsavedChangesTitle": "放弃未保存的修改?", "unsavedChanges": "当前修改尚未保存。要放弃修改并继续吗?", "discardAndContinue": "放弃并继续", "general": { - "title": "项目概览", - "description": "在这里修改项目运行配置;点击右上角保存前,所有变化只保留为草稿。", "template": "Template", "toolchain": "Toolchain", "path": "项目路径", "buildVersion": "构建版本", "packageManager": "包管理器", - "packageManagerAutomatic": "自动检测", "devCommand": "开发命令", "buildCommand": "构建命令", "buildSource": "one build 自动读取 {{source}},请在该文件中修改构建任务。 工作区依赖与缓存由 mise 管理,one run build 使用相同入口。", @@ -372,7 +213,6 @@ }, "environment": { "title": "环境配置", - "description": "项目路径、继承和禁用设置保存在清单中,Infisical 项目绑定在工作区层级管理。", "path": "Infisical 文件夹路径", "inherits": "继承工作区环境", "inheritsHint": "项目变量会覆盖同名工作区变量。", @@ -380,31 +220,14 @@ "disabledHint": "运行项目命令时不注入环境变量。", "keys": "已声明变量名" }, - "profile": { - "label": "项目 Profile", - "description": "选择仅对这个项目和当前环境生效的机器级凭据。", - "notRequired": "这个 Backend 不需要凭据 Profile。", - "loading": "正在加载凭据 Profile…", - "loadFailed": "无法加载凭据 Profile。", - "automatic": "自动解析(工作区 / 默认)", - "inherited": "继承 {{name}}({{source}})", - "none": "尚未解析到 Profile", - "manage": "前往设置管理" - }, - "draftHint": "修改将暂存为草稿,预览并保存后生效。", - "runtime": { - "title": "运行与控制台" - } + "draftHint": "修改将暂存为草稿,预览并保存后生效。" }, "secrets": { "title": "Infisical 密钥", - "description": "管理当前环境和作用域中保存于远端的密钥值。", "unavailableTitle": "远端密钥尚未启用", "unavailableDescription": "请在工作区设置中绑定 Infisical 项目,以管理远端变量。", - "configureBackend": "配置环境 Backend", "scope": "密钥作用域", "workspaceScope": "Workspace 共享", - "directOnly": "这里只能修改当前 folder 直接定义的值,不会改动继承的上层密钥。", "add": "新增密钥", "key": "Key", "value": "值", @@ -441,121 +264,26 @@ }, "project": { "fields": { - "bucket": "Bucket", - "environment": "部署环境", - "projectId": "Project ID", - "projectName": "项目名称", - "workerName": "Worker 名称" + "environment": "部署环境" } }, "overview": { - "untitledWorkspace": "未命名 workspace", - "environments": "环境", "projects": "子项目", - "empty": "manifest 里没有声明任何子项目。", - "allGood": "✓ 配置齐全", - "workspaceIssuesTitle": "工作区级配置缺失", - "manageProfilesCta": "管理凭据 profile", - "profileSafety": "Profile 保存在本机配置中,凭据不会写入 manifest。", "workspaceEnv": { "title": "工作区环境变量", - "scope": "Workspace 级", - "readOnly": "只读", "description": "配置工作区环境存储,并管理共享密钥。", - "localBinding": "机器本地 Profile 绑定", - "manifestLegend": "共享 Manifest", - "localLegend": "本机配置", "backend": "Backend", - "backendDescription": "工作区统一使用 Infisical 管理环境变量。", - "backendManifest": "Manifest 配置", "backendPending": "等待审阅", "backendSource": "Infisical 项目绑定变更经审阅后写入 one.manifest.json。", - "backendPendingHint": "已加入顶部的待提交改动。保存时 One CLI 会完成 Backend 初始化;完成后即可选择对应的本机 Profile。", - "backendMissing": "未配置", - "loading": "正在加载 Workspace Profile 绑定…", - "loadFailed": "无法加载 Workspace Profile 绑定。", - "missingBackendCli": "请先选择环境变量 Backend,再选择 Profile。", - "saved": "Workspace Profile 已保存", - "saveFailed": "无法保存 Workspace Profile", - "profile": { - "title": "凭据 Profile", - "label": "Profile", - "description": "为这个 Workspace 和当前环境选择一个机器本地的凭据 Profile。", - "localBadge": "仅本机", - "waitTitle": "先应用 Backend 修改", - "waitDescription": "目标 Backend 生效后,系统会重新读取它需要的 Profile;这里不会提前保存到错误的 Backend。", - "notRequired": "这个 Backend 不需要凭据 Profile。", - "loading": "正在加载凭据 Profile…", - "loadFailed": "无法加载凭据 Profile。", - "automatic": "自动解析(机器默认)", - "inherited": "自动 · {{name}}({{source}})", - "none": "当前未解析到 Profile", - "manage": "前往设置管理" - }, "signInHint": "请先在设置中登录 Infisical,再选择存储项目。", "noProjects": "暂无可用的存储项目,可在共享凭据中创建。" }, "tabs": { - "label": "工作区分区", "environment": "工作区环境变量", - "projects": "项目", "secrets": "Infisical 密钥" }, "navigation": { - "label": "工作区页面导航", - "overview": "待处理", - "projects": "项目", - "secrets": "密钥", - "settings": "工作区设置", - "attention": "{{count}} 项需要处理", - "ready": "当前没有阻塞项", - "projectCount": "{{count}} 个项目", - "infisical": "Infisical 已启用", - "notEnabled": "尚未启用远端密钥", - "backendProfile": "Backend 与 Profile" - }, - "actionCenter": { - "eyebrow": "下一步", - "pending": "项需要处理", - "clear": "项阻塞", - "description": "按顺序处理下面的问题,完成后再运行对应的 One CLI 命令。", - "clearDescription": "工作区的核心配置已经可以使用。", - "title": "优先处理", - "hint": "每一项都直接进入对应的配置位置。", - "viewProjects": "查看全部项目", - "workspaceLabel": "工作区配置", - "configureWorkspace": "配置工作区", - "resolve": "立即处理", - "allGood": "当前没有待处理项", - "allGoodDescription": "可以继续管理密钥,或检查各项目的详细配置。" - }, - "command": { - "ready": "可以运行", - "review": "检查配置" - }, - "metrics": { - "projects": "项目", - "healthy": "正常", - "attention": "待处理" - }, - "kinds": { - "app": "App", - "service": "Service", - "package": "Package" - }, - "issue": { - "label": { - "env": "env", - "profile": "profile" - }, - "missingProfile": "{{section}} 缺少可用凭据 profile{{profile}},请补齐 API key / token。", - "missing": { - "env": "workspace 未选择 env backend,请先配置再运行 `one env` 相关命令。" - } - }, - "fix": { - "profileCta": "补凭据", - "cliHint": "请使用 One CLI 配置这个 Backend。" + "settings": "工作区设置" } }, "session": { diff --git a/apps/dashboard/src/router/SettingsDialog.tsx b/apps/dashboard/src/router/SettingsDialog.tsx deleted file mode 100644 index 86f0729..0000000 --- a/apps/dashboard/src/router/SettingsDialog.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { AccountSettings } from "@/features/infisical-session/AccountSettings"; -export function SettingsDialog() { - const { t } = useTranslation(); - return ( - - - - - - - {t("sidebar.settings")} - {t("session.description")} - - - - - ); -} diff --git a/packages/cli/internal/adapters/env/infisical/client.go b/packages/cli/internal/adapters/env/infisical/client.go index 39aed62..579afdc 100644 --- a/packages/cli/internal/adapters/env/infisical/client.go +++ b/packages/cli/internal/adapters/env/infisical/client.go @@ -38,11 +38,6 @@ func NewClient(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials) (* // that has it, and the secrets layer doesn't need it for any decision. var clientVersion = "0.0.0-dev" -// SetVersion lets the cobra root inject the build-time version into the -// HTTP user-agent header so Infisical-side observability can spot specific -// CLI revisions (helpful when triaging a regression). -func SetVersion(v string) { clientVersion = v } - // ListSecrets reads every key at the given path/environment, optionally // recursively when the caller requests a folder subtree. func (c *Client) ListSecrets(env, secretPath string, recursive bool) ([]models.Secret, error) { @@ -208,7 +203,6 @@ func (c *Client) VerifyProjectExists(env string) error { // ----- error helpers below ----- -func mapAuthError(err error) error { return mapAPIError(err) } func mapAPIError(err error) error { if err == nil { return nil diff --git a/packages/cli/internal/adapters/env/infisical/crud.go b/packages/cli/internal/adapters/env/infisical/crud.go index bfe28fb..3584ca0 100644 --- a/packages/cli/internal/adapters/env/infisical/crud.go +++ b/packages/cli/internal/adapters/env/infisical/crud.go @@ -3,8 +3,6 @@ package infisical import ( "context" "errors" - "fmt" - "io" "sort" "strings" @@ -41,14 +39,6 @@ type SetResult struct { Action string `json:"action"` } -// RenderTTY prints a one-line set confirmation. -func (r *SetResult) RenderTTY(w io.Writer) { - if r == nil { - return - } - fmt.Fprintf(w, i18n.T("env.set_success_remote")+"\n", r.Key, r.Path, r.Env) -} - // Set writes a single key into Infisical. Auto-detects whether the key // already exists and routes to Update vs Create accordingly. Returns // ENV_SET_OVERWRITE_REQUIRED when an existing key would be modified @@ -238,16 +228,6 @@ func Delete(ctx context.Context, projectRoot string, in DeleteInput) (*DeleteRes }, nil } -// RenderTTY prints the keys, one per line (no values). -func (r *ListResult) RenderTTY(w io.Writer) { - if r == nil { - return - } - for _, k := range r.Keys { - fmt.Fprintln(w, k) - } -} - // List returns the keys at a given path/env (without values). Recursive // mode walks subfolders too. func List(ctx context.Context, projectRoot string, in ListInput) (*ListResult, error) { diff --git a/packages/cli/internal/adapters/env/infisical/init.go b/packages/cli/internal/adapters/env/infisical/init.go index 0d3f0bc..788d2e1 100644 --- a/packages/cli/internal/adapters/env/infisical/init.go +++ b/packages/cli/internal/adapters/env/infisical/init.go @@ -6,8 +6,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" - "io" "os" "path/filepath" "strings" @@ -56,26 +54,6 @@ type InitResult struct { WrittenTo string `json:"written_to"` // absolute path to one.manifest.json } -// RenderTTY prints the init outcome. -func (r *InitResult) RenderTTY(w io.Writer) { - if r == nil { - return - } - fmt.Fprintln(w, i18n.T("infisical.init.success")) - if r.Created { - fmt.Fprintf(w, i18n.T("infisical.init.project_created"), r.ProjectName, r.ProjectID) - } else if r.ProjectName != "" { - fmt.Fprintf(w, i18n.T("infisical.init.project_named"), r.ProjectName, r.ProjectID) - } else { - fmt.Fprintf(w, i18n.T("infisical.init.project"), r.ProjectID) - } - fmt.Fprintf(w, i18n.T("infisical.init.environments"), - strings.Join(r.Environments, ", "), r.DefaultEnv) - fmt.Fprintf(w, i18n.T("infisical.init.path"), r.RootPath) - fmt.Fprintf(w, i18n.T("infisical.init.auth"), r.AuthStatus) - fmt.Fprintf(w, i18n.T("infisical.init.written"), r.WrittenTo) -} - // maxCreateProjectRetries caps the suffix-retry loop. Five 4-char hex // suffixes give a 20-bit search space — collisions on every attempt would // indicate Infisical-side trouble, not legitimate name competition. diff --git a/packages/cli/internal/adapters/shared/helpers.go b/packages/cli/internal/adapters/shared/helpers.go deleted file mode 100644 index b1d81c5..0000000 --- a/packages/cli/internal/adapters/shared/helpers.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package internalcommon holds string helpers shared by the bundled -// infra backends. Lives under internal/adapters/ so it stays a sibling of -// the backend packages without becoming part of the public API. -package internalcommon - -import ( - "strings" -) - -// NormalizeNewlines folds CRLF line endings into LF so backend output -// is consistent regardless of how the user's tools (or git) wrote the -// file we're editing. -func NormalizeNewlines(s string) string { - return strings.ReplaceAll(s, "\r\n", "\n") -} - -// EnsureTrailingNewline guarantees the returned string ends with one -// "\n", which is the convention every infra file in this repo follows. -func EnsureTrailingNewline(s string) string { - if strings.HasSuffix(s, "\n") { - return s - } - return s + "\n" -} diff --git a/packages/cli/internal/application/manifest/service_test.go b/packages/cli/internal/application/manifest/service_test.go index 9636918..608438a 100644 --- a/packages/cli/internal/application/manifest/service_test.go +++ b/packages/cli/internal/application/manifest/service_test.go @@ -253,11 +253,8 @@ func TestWorkspaceBindingAndProjectChangesPublishTogether(t *testing.T) { if err != nil { t.Fatal(err) } - var config map[string]string - if err = json.Unmarshal(workspacecore.EnvConfigRaw(manifest), &config); err != nil { - t.Fatal(err) - } - if config["projectId"] != id || config["siteUrl"] != site || config["projectName"] != name { + config := manifest.Env + if config == nil || config.ProjectID != id || config.SiteURL != site || config.ProjectName != name { t.Fatalf("binding: %#v", config) } } diff --git a/packages/cli/internal/core/backend/catalog.go b/packages/cli/internal/core/backend/catalog.go index fdfc5f6..f91b9fa 100644 --- a/packages/cli/internal/core/backend/catalog.go +++ b/packages/cli/internal/core/backend/catalog.go @@ -110,20 +110,6 @@ func cloneSpec(spec BackendSpec) BackendSpec { return spec } -// WithTrait returns descriptors in stable catalog order. -func (c *Catalog) WithTrait(trait Trait) []BackendSpec { - if c == nil { - return nil - } - var out []BackendSpec - for _, spec := range c.ordered { - if spec.HasTrait(trait) { - out = append(out, cloneSpec(spec)) - } - } - return out -} - // All returns descriptors in the stable product order declared by Builtin. func (c *Catalog) All() []BackendSpec { if c == nil { @@ -164,16 +150,6 @@ func (c *Catalog) LookupPair(pair string) (BackendSpec, bool) { return cloneSpec(spec), ok } -// Names returns the bare backend names for a domain in catalog order. -func (c *Catalog) Names(domain Domain) []string { - specs := c.ForDomain(domain) - out := make([]string, len(specs)) - for i, spec := range specs { - out[i] = spec.ID.Name - } - return out -} - // SortedPairs is intended for diagnostics and golden assertions. func (c *Catalog) SortedPairs() []string { if c == nil { diff --git a/packages/cli/internal/core/backend/types.go b/packages/cli/internal/core/backend/types.go index 5d7facb..d9bec72 100644 --- a/packages/cli/internal/core/backend/types.go +++ b/packages/cli/internal/core/backend/types.go @@ -24,11 +24,6 @@ const ( EnvInfisical = "infisical" ) -// Domains is the stable product display order. -func Domains() []Domain { - return []Domain{DomainEnv} -} - // BackendID is the canonical identity of one backend. String renders the // transport identity used by the backend catalog. type BackendID struct { @@ -72,8 +67,6 @@ const ( // without maintaining a second backend identity list. type Trait string -const () - // RequirementKind describes a dependency that must be satisfied before a // backend operation begins. type RequirementKind string @@ -165,13 +158,3 @@ func (s BackendSpec) Has(capability Capability) bool { } return false } - -// HasTrait reports whether the backend belongs to a shared protocol family. -func (s BackendSpec) HasTrait(trait Trait) bool { - for _, item := range s.Traits { - if item == trait { - return true - } - } - return false -} diff --git a/packages/cli/internal/core/workspace/backend.go b/packages/cli/internal/core/workspace/backend.go index f3613cf..b23f564 100644 --- a/packages/cli/internal/core/workspace/backend.go +++ b/packages/cli/internal/core/workspace/backend.go @@ -1,9 +1,6 @@ package workspace import ( - "encoding/json" - "strings" - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" ) @@ -20,24 +17,6 @@ func EnvBackend(m *Manifest) string { return EnvBackendInfisical } -// EnvConfigRaw serializes the typed Infisical binding, or returns nil when unset. -func EnvConfigRaw(m *Manifest) json.RawMessage { - if m == nil || m.Env == nil { - return nil - } - raw, _ := json.Marshal(m.Env) - return raw -} - -// WorkspaceID returns the workspace identity id, or "" when older -// manifests have not been back-filled yet. -func WorkspaceID(m *Manifest) string { - if m == nil || m.Workspace == nil { - return "" - } - return strings.TrimSpace(m.Workspace.ID) -} - // SelectionForProject projects the environment source for template compatibility checks. func SelectionForProject(m *Manifest, project *ManifestProject) map[string]string { out := map[string]string{} diff --git a/packages/cli/internal/core/workspace/backend_apply.go b/packages/cli/internal/core/workspace/backend_apply.go deleted file mode 100644 index eacd5da..0000000 --- a/packages/cli/internal/core/workspace/backend_apply.go +++ /dev/null @@ -1,35 +0,0 @@ -package workspace - -import "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - -// SetProjectBuildVersion writes projects[name].buildVersion. Versions are -// stored without a leading "v" even when Docker tags use one. -func SetProjectBuildVersion(projectRoot, projectName, version string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - idx, err := projectIndex(m, projectName) - if err != nil { - return err - } - m.Projects[idx].BuildVersion = NormalizeBuildVersion(version) - return WriteManifest(projectRoot, m) -} - -// projectIndex returns the index of projectName in m.Projects, or an -// error if not found. -func projectIndex(m *Manifest, projectName string) (int, error) { - for i := range m.Projects { - if m.Projects[i].Name == projectName { - return i, nil - } - } - return -1, i18n.Errorf("workspace.project_missing", projectName) -} - -func ensureWorkspaceEnv(m *Manifest) { - if m.Env == nil { - m.Env = &EnvironmentConfig{} - } -} diff --git a/packages/cli/internal/core/workspace/backend_test.go b/packages/cli/internal/core/workspace/backend_test.go deleted file mode 100644 index db95f40..0000000 --- a/packages/cli/internal/core/workspace/backend_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package workspace - -// Locks the read/write helpers for the current deploy/container fields: -// -// - projects[i].domains.container.namespace (per-project) -// - projects[i].domains.deploy.config.bucket (per-project, s3 only) -// - manifest.domains.deploy.config.namespace (workspace, kustomize only) -// - manifest.domains.deploy.config.kustomizationPath (workspace, kustomize only) - -import ( - "encoding/json" - "testing" -) - -// rawConfig is a small helper that JSON-encodes a struct into a -// json.RawMessage suitable for stuffing into BackendRef.Config / -// ProjectDeployBackend.Config. Test-only; production sites use the typed -// per-kind config Encode helpers. -func rawConfig(t *testing.T, v any) json.RawMessage { - t.Helper() - raw, err := json.Marshal(v) - if err != nil { - t.Fatalf("rawConfig: %v", err) - } - return raw -} diff --git a/packages/cli/internal/core/workspace/build_version.go b/packages/cli/internal/core/workspace/build_version.go index 38b7042..36c2916 100644 --- a/packages/cli/internal/core/workspace/build_version.go +++ b/packages/cli/internal/core/workspace/build_version.go @@ -14,25 +14,3 @@ func NormalizeBuildVersion(version string) string { } return version } - -func BuildTagForVersion(version string) string { - version = NormalizeBuildVersion(version) - return "v" + version -} - -func BuildVersionForProject(m *Manifest, projectName string) string { - if m == nil { - return "" - } - projectName = strings.TrimSpace(projectName) - for _, p := range m.Projects { - if projectName != "" && p.Name != projectName { - continue - } - if strings.TrimSpace(p.BuildVersion) == "" { - return "" - } - return NormalizeBuildVersion(p.BuildVersion) - } - return "" -} diff --git a/packages/cli/internal/core/workspace/environment_compat.go b/packages/cli/internal/core/workspace/environment_compat.go deleted file mode 100644 index 46aea08..0000000 --- a/packages/cli/internal/core/workspace/environment_compat.go +++ /dev/null @@ -1,29 +0,0 @@ -package workspace - -import "strings" - -// ProfileBindingEnvironment maps the Dashboard's stable environment names to -// the machine-local binding key used by a workspace. v1 workspaces created -// before Preview became the product term declared "staging" instead. Keep -// those repositories byte-for-byte unchanged while letting the UI address -// their existing staging binding through the Preview selector. -func ProfileBindingEnvironment(manifest *Manifest, requested string) string { - requested = strings.TrimSpace(requested) - if requested != "preview" || manifest == nil || manifest.Environments == nil { - return requested - } - hasPreview := false - hasStaging := false - for _, candidate := range manifest.Environments.Names { - switch strings.TrimSpace(candidate) { - case "preview": - hasPreview = true - case "staging": - hasStaging = true - } - } - if hasStaging && !hasPreview { - return "staging" - } - return requested -} diff --git a/packages/cli/internal/core/workspace/environment_compat_test.go b/packages/cli/internal/core/workspace/environment_compat_test.go deleted file mode 100644 index 98a4ef5..0000000 --- a/packages/cli/internal/core/workspace/environment_compat_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package workspace - -import ( - "reflect" - "testing" -) - -func TestProfileBindingEnvironmentMapsLegacyPreviewToStaging(t *testing.T) { - legacy := &Manifest{Environments: &Environments{ - Names: []string{"dev", "staging", "prod"}, Default: "dev", - }} - if got := ProfileBindingEnvironment(legacy, "preview"); got != "staging" { - t.Fatalf("legacy preview binding key = %q; want staging", got) - } - if got := ProfileBindingEnvironment(legacy, "prod"); got != "prod" { - t.Fatalf("prod binding key = %q", got) - } - modern := &Manifest{Environments: &Environments{ - Names: []string{"dev", "preview", "prod"}, Default: "dev", - }} - if got := ProfileBindingEnvironment(modern, "preview"); got != "preview" { - t.Fatalf("modern preview binding key = %q", got) - } - mixed := &Manifest{Environments: &Environments{ - Names: []string{"dev", "preview", "staging", "prod"}, Default: "dev", - }} - if got := ProfileBindingEnvironment(mixed, "preview"); got != "preview" { - t.Fatalf("explicit preview must win over staging; got %q", got) - } -} - -func TestNewWorkspaceDefaultEnvironmentsUsePreview(t *testing.T) { - want := []string{"dev", "preview", "prod"} - if !reflect.DeepEqual(DefaultEnvironments, want) { - t.Fatalf("DefaultEnvironments = %#v; want %#v", DefaultEnvironments, want) - } -} diff --git a/packages/cli/internal/core/workspace/environment_defaults_test.go b/packages/cli/internal/core/workspace/environment_defaults_test.go new file mode 100644 index 0000000..62e7461 --- /dev/null +++ b/packages/cli/internal/core/workspace/environment_defaults_test.go @@ -0,0 +1,13 @@ +package workspace + +import ( + "reflect" + "testing" +) + +func TestNewWorkspaceDefaultEnvironmentsUsePreview(t *testing.T) { + want := []string{"dev", "preview", "prod"} + if !reflect.DeepEqual(DefaultEnvironments, want) { + t.Fatalf("DefaultEnvironments = %#v; want %#v", DefaultEnvironments, want) + } +} diff --git a/packages/cli/internal/core/workspace/infra.go b/packages/cli/internal/core/workspace/infra.go deleted file mode 100644 index 015acfe..0000000 --- a/packages/cli/internal/core/workspace/infra.go +++ /dev/null @@ -1,42 +0,0 @@ -package workspace - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "regexp" - "strings" -) - -// ResolveProjectWorkflowPath returns the canonical -// .github/workflows/ci-.yml path for a project. Used by status to -// report whether each project has a workflow on disk. -func ResolveProjectWorkflowPath(projectRoot, targetDir string) string { - rel, err := filepath.Rel(projectRoot, targetDir) - if err != nil { - rel = targetDir - } - rel = ToPosixPath(rel) - id := workflowIDFromRelativeDir(rel) - return filepath.Join(projectRoot, ".github", "workflows", "ci-"+id+".yml") -} - -// HasProjectWorkflow reports whether the project's workflow file exists. -func HasProjectWorkflow(projectRoot, targetDir string) bool { - _, err := os.Stat(ResolveProjectWorkflowPath(projectRoot, targetDir)) - return err == nil && !errors.Is(err, fs.ErrNotExist) -} - -var ( - pathSeparators = regexp.MustCompile(`[\\/]+`) - nonAllowedChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) - repeatedDashes = regexp.MustCompile(`-+`) -) - -func workflowIDFromRelativeDir(rel string) string { - id := pathSeparators.ReplaceAllString(rel, "-") - id = nonAllowedChars.ReplaceAllString(id, "-") - id = repeatedDashes.ReplaceAllString(id, "-") - return strings.ToLower(id) -} diff --git a/packages/cli/internal/core/workspace/json_order.go b/packages/cli/internal/core/workspace/json_order.go deleted file mode 100644 index 467ceac..0000000 --- a/packages/cli/internal/core/workspace/json_order.go +++ /dev/null @@ -1,111 +0,0 @@ -package workspace - -import ( - "bytes" - "encoding/json" - "errors" - "io" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -// parseScriptKeysInOrder walks raw package.json bytes and returns the keys -// inside the top-level "scripts" object in their on-disk order. Returns nil -// if the file has no "scripts" object. -// -// Go's map iteration is randomised, so we can't trust json.Unmarshal + -// `for k := range m`. encoding/json's Decoder.Token API streams tokens in -// source order, which is exactly what we need. -func parseScriptKeysInOrder(raw []byte) ([]string, error) { - dec := json.NewDecoder(bytes.NewReader(raw)) - tok, err := dec.Token() - if err != nil { - return nil, err - } - if delim, ok := tok.(json.Delim); !ok || delim != '{' { - return nil, errors.New(i18n.T("workspace.package_object")) - } - for dec.More() { - keyTok, err := dec.Token() - if err != nil { - return nil, err - } - key, ok := keyTok.(string) - if !ok { - return nil, errors.New(i18n.T("workspace.package_key")) - } - if key != "scripts" { - if err := skipValue(dec); err != nil { - return nil, err - } - continue - } - // Found the scripts field. The next token should be `{`. - open, err := dec.Token() - if err != nil { - return nil, err - } - if d, ok := open.(json.Delim); !ok || d != '{' { - // `scripts: null` or non-object — treat as no scripts. - return []string{}, nil - } - out := []string{} - for dec.More() { - scriptKeyTok, err := dec.Token() - if err != nil { - return nil, err - } - scriptKey, ok := scriptKeyTok.(string) - if !ok { - return nil, errors.New(i18n.T("workspace.script_key")) - } - out = append(out, scriptKey) - if err := skipValue(dec); err != nil { - return nil, err - } - } - // Closing `}` of scripts. - if _, err := dec.Token(); err != nil { - return nil, err - } - return out, nil - } - return []string{}, nil -} - -// skipValue consumes the next JSON value (object, array, or scalar) from the -// decoder and discards it. Used by parseScriptKeysInOrder to step past -// non-scripts fields without allocating their full structure. -func skipValue(dec *json.Decoder) error { - tok, err := dec.Token() - if err != nil { - return err - } - delim, ok := tok.(json.Delim) - if !ok { - return nil // scalar: already consumed. - } - switch delim { - case '{', '[': - // recurse until the matching closer. - depth := 1 - for depth > 0 { - t, err := dec.Token() - if err != nil { - if errors.Is(err, io.EOF) { - return io.ErrUnexpectedEOF - } - return err - } - if d, ok := t.(json.Delim); ok { - switch d { - case '{', '[': - depth++ - case '}', ']': - depth-- - } - } - } - } - return nil -} diff --git a/packages/cli/internal/core/workspace/manifest.go b/packages/cli/internal/core/workspace/manifest.go index 8c4f026..e680a02 100644 --- a/packages/cli/internal/core/workspace/manifest.go +++ b/packages/cli/internal/core/workspace/manifest.go @@ -156,11 +156,6 @@ func HasManifest(projectRoot string) bool { return err == nil } -// IsOneProjectRoot is an alias for HasManifest. -func IsOneProjectRoot(projectRoot string) bool { - return HasManifest(projectRoot) -} - // ReadManifest loads and validates the manifest. Returns an empty manifest // (no error) when the file does not exist. Only the current ManifestVersion // is accepted; older manifests must be migrated by hand (see CHANGELOG). diff --git a/packages/cli/internal/core/workspace/manifest_roundtrip_test.go b/packages/cli/internal/core/workspace/manifest_roundtrip_test.go index 548f069..9e31f8a 100644 --- a/packages/cli/internal/core/workspace/manifest_roundtrip_test.go +++ b/packages/cli/internal/core/workspace/manifest_roundtrip_test.go @@ -1,20 +1,6 @@ package workspace -// Manifest write/read round-trip + upsert/rebuild invariants. -// -// The manifest is the data structure that status / add all converge on; -// bugs here typically present as "status reports drift on a fresh write" -// or "add wiped a per-subproject env override". This file pins the -// load-bearing properties: -// 1. Read on missing → empty manifest (no error) -// 2. Write→Read produces matching content -// 3. Upsert preserves per-subproject env / container / deploy overrides -// 4. Rebuild preserves overrides for entries that survive -// 5. RelativeDir is normalized to POSIX form on persist -// 6. Repeated WriteManifest produces byte-identical output (deterministic) -// -// Timestamps (createdAt / updatedAt) were removed in v0.7 — they were -// merge-conflict bait and git already tells you who changed what. +// Manifest persistence preserves project overrides, canonical ordering, and deterministic bytes. import ( "os" @@ -98,10 +84,10 @@ func TestManifest_WriteIsByteDeterministic(t *testing.T) { func TestManifest_PreservesDevOverride(t *testing.T) { tmp := t.TempDir() - if err := UpsertManifestProject(tmp, ManifestProjectInput{ + if err := WriteManifest(tmp, &Manifest{Projects: []ManifestProject{{ Name: "api", RelativeDir: "services/api", TemplateID: "nestjs-api", Toolchain: "node", PackageManager: "pnpm", - }); err != nil { - t.Fatalf("upsert: %v", err) + }}}); err != nil { + t.Fatalf("WriteManifest: %v", err) } if err := UpdateProjectDev(tmp, "services/api", "pnpm run start:dev"); err != nil { t.Fatalf("UpdateProjectDev: %v", err) @@ -110,14 +96,16 @@ func TestManifest_PreservesDevOverride(t *testing.T) { t.Errorf("dev.command after write = %q, want %q", got, "pnpm run start:dev") } - // Re-upsert the project — Domains must survive. - if err := UpsertManifestProject(tmp, ManifestProjectInput{ - Name: "api", RelativeDir: "services/api", TemplateID: "nestjs-api", Toolchain: "node", PackageManager: "pnpm", - }); err != nil { - t.Fatalf("re-upsert: %v", err) + // Read and persist the manifest again; the dev command must survive. + m, err := ReadManifest(tmp) + if err != nil { + t.Fatal(err) + } + if err := WriteManifest(tmp, m); err != nil { + t.Fatal(err) } if got := readDevCommand(t, tmp, "api"); got != "pnpm run start:dev" { - t.Errorf("dev.command lost on upsert: got %q", got) + t.Errorf("dev.command lost on round-trip: got %q", got) } // Empty command clears the block. @@ -131,8 +119,8 @@ func TestManifest_PreservesDevOverride(t *testing.T) { if err != nil { t.Fatalf("ReadManifest after clear: %v", err) } - if loaded.Projects[0].Env != nil { - t.Errorf("Env should be cleared to nil when all overrides cleared, got %+v", loaded.Projects[0].Env) + if loaded.Projects[0].Dev != nil { + t.Errorf("Dev should be cleared to nil, got %+v", loaded.Projects[0].Dev) } } @@ -145,28 +133,14 @@ func readDevCommand(t *testing.T, root, projectName string) string { return ProjectDev(m, projectName) } -func TestManifest_UpsertPreservesEnvOverride(t *testing.T) { +func TestManifest_WriteReadPreservesOverrides(t *testing.T) { tmp := t.TempDir() - - if err := UpsertManifestProject(tmp, ManifestProjectInput{ - Name: "billing", RelativeDir: "services/billing", TemplateID: "go-api", Toolchain: "go", - }); err != nil { - t.Fatalf("first upsert: %v", err) - } - // Stamp some env metadata so upsert preservation is observable. - if err := UpdateManifestProjectEnv(tmp, "services/billing", &ProjectEnvOverride{ - Path: "/teams/payments/billing", Keys: []string{"DATABASE_URL"}, - }); err != nil { - t.Fatalf("UpdateManifestProjectEnv: %v", err) - } - if err := SetProjectBuildVersion(tmp, "billing", "1.2.3"); err != nil { - t.Fatalf("SetProjectBuildVersion: %v", err) - } - - if err := UpsertManifestProject(tmp, ManifestProjectInput{ + if err := WriteManifest(tmp, &Manifest{Projects: []ManifestProject{{ Name: "billing", RelativeDir: "services/billing", TemplateID: "go-api", Toolchain: "go", - }); err != nil { - t.Fatalf("second upsert: %v", err) + BuildVersion: "1.2.3", + Env: &ProjectEnvOverride{Path: "/teams/payments/billing", Keys: []string{"DATABASE_URL"}}, + }}}); err != nil { + t.Fatalf("WriteManifest: %v", err) } loaded, err := ReadManifest(tmp) @@ -178,88 +152,31 @@ func TestManifest_UpsertPreservesEnvOverride(t *testing.T) { } sub := loaded.Projects[0] if sub.Env == nil { - t.Fatalf("env override lost on upsert") + t.Fatalf("env override lost on round-trip") } if sub.Env.Path != "/teams/payments/billing" { - t.Errorf("env.path drifted on upsert: got %q", sub.Env.Path) + t.Errorf("env.path drifted on round-trip: got %q", sub.Env.Path) } if len(sub.Env.Keys) != 1 || sub.Env.Keys[0] != "DATABASE_URL" { - t.Errorf("env.keys drifted on upsert: got %v", sub.Env.Keys) + t.Errorf("env.keys drifted on round-trip: got %v", sub.Env.Keys) } if sub.BuildVersion != "1.2.3" { - t.Errorf("buildVersion drifted on upsert: got %q", sub.BuildVersion) - } -} - -func TestManifest_RebuildPreservesOverrides(t *testing.T) { - tmp := t.TempDir() - - // Step 1: seed two entries with overrides. - for _, name := range []string{"a", "b"} { - if err := UpsertManifestProject(tmp, ManifestProjectInput{ - Name: name, RelativeDir: "services/" + name, TemplateID: "go-api", Toolchain: "go", - }); err != nil { - t.Fatalf("seed %s: %v", name, err) - } - } - if err := UpdateManifestProjectEnv(tmp, "services/a", &ProjectEnvOverride{ - Keys: []string{"FOO"}, - }); err != nil { - t.Fatalf("seed env: %v", err) - } - if err := SetProjectBuildVersion(tmp, "a", "2.3.4"); err != nil { - t.Fatalf("seed buildVersion: %v", err) - } - - // Step 2: rebuild — drop b, keep a, add c. - rebuilt, err := RebuildManifest(tmp, []ManifestProjectInput{ - {Name: "a", RelativeDir: "services/a", TemplateID: "go-api", Toolchain: "go"}, - {Name: "c", RelativeDir: "services/c", TemplateID: "go-api", Toolchain: "go"}, - }) - if err != nil { - t.Fatalf("rebuild: %v", err) - } - if len(rebuilt.Projects) != 2 { - t.Fatalf("after rebuild: want 2 entries, got %d", len(rebuilt.Projects)) - } - - byName := map[string]ManifestProject{} - for _, s := range rebuilt.Projects { - byName[s.Name] = s - } - - a := byName["a"] - if a.Env == nil || - len(a.Env.Keys) == 0 || a.Env.Keys[0] != "FOO" { - t.Errorf("a.env.keys should survive rebuild, got %+v", a.Env) - } - if a.BuildVersion != "2.3.4" { - t.Errorf("a.buildVersion should survive rebuild, got %q", a.BuildVersion) - } - - c := byName["c"] - if c.Env != nil { - t.Errorf("c (new) should have no env override, got %+v", c.Env) - } - if c.BuildVersion != DefaultBuildVersion { - t.Errorf("c.buildVersion: want %s, got %q", DefaultBuildVersion, c.BuildVersion) - } - - if _, exists := byName["b"]; exists { - t.Error("b should be dropped — not in rebuild input") + t.Errorf("buildVersion drifted on round-trip: got %q", sub.BuildVersion) } } func TestManifest_SubprojectsAlwaysSortedByRelativeDir(t *testing.T) { tmp := t.TempDir() - // Insert in reverse alphabetical order; expect sorted output. + // Persist projects out of order; expect sorted output. + m := &Manifest{} for _, name := range []string{"zeta", "alpha", "mu"} { - if err := UpsertManifestProject(tmp, ManifestProjectInput{ + m.Projects = append(m.Projects, ManifestProject{ Name: name, RelativeDir: "services/" + name, TemplateID: "go-api", Toolchain: "go", - }); err != nil { - t.Fatalf("upsert %s: %v", name, err) - } + }) + } + if err := WriteManifest(tmp, m); err != nil { + t.Fatal(err) } loaded, err := ReadManifest(tmp) @@ -277,10 +194,10 @@ func TestManifest_SubprojectsAlwaysSortedByRelativeDir(t *testing.T) { func TestManifest_RecordProjectEnvKey(t *testing.T) { tmp := t.TempDir() - if err := UpsertManifestProject(tmp, ManifestProjectInput{ + if err := WriteManifest(tmp, &Manifest{Projects: []ManifestProject{{ Name: "api", RelativeDir: "services/api", TemplateID: "go-api", Toolchain: "go", - }); err != nil { - t.Fatalf("upsert: %v", err) + }}}); err != nil { + t.Fatalf("WriteManifest: %v", err) } // Add two keys, dedupe attempt, sorted result. diff --git a/packages/cli/internal/core/workspace/manifest_test.go b/packages/cli/internal/core/workspace/manifest_test.go index 1187c6f..42ae65e 100644 --- a/packages/cli/internal/core/workspace/manifest_test.go +++ b/packages/cli/internal/core/workspace/manifest_test.go @@ -67,9 +67,6 @@ func TestManifest_RoundTrip(t *testing.T) { if got.Env == nil || EnvBackend(got) != EnvBackendInfisical { t.Errorf("env backend not preserved: %+v", got.Env) } - if EnvConfigRaw(got) == nil { - t.Errorf("env config not preserved") - } if len(got.Projects) != 2 { t.Fatalf("projects count = %d; want 2", len(got.Projects)) } @@ -155,61 +152,3 @@ func TestInitWorkspaceEnv_PreservesProjects(t *testing.T) { t.Fatalf("projects wiped: %d", len(got.Projects)) } } - -func TestUpsertManifestProject_PreservesDomainsOverride(t *testing.T) { - tmp := t.TempDir() - - // Seed a project with an env override. - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{{ - Name: "api", - RelativeDir: "services/api", - TemplateID: "go-api", - Toolchain: "go", - - Env: &ProjectEnvOverride{Disabled: true}, - }}, - }); err != nil { - t.Fatal(err) - } - - // Re-upsert (e.g. `one add` re-running on an existing project). - if err := UpsertManifestProject(tmp, ManifestProjectInput{ - Name: "api", - RelativeDir: "services/api", - TemplateID: "go-api", - Toolchain: "go", - }); err != nil { - t.Fatalf("UpsertManifestProject: %v", err) - } - - got, _ := ReadManifest(tmp) - if len(got.Projects) != 1 { - t.Fatalf("projects count = %d; want 1", len(got.Projects)) - } - if got.Projects[0].Env == nil || - !got.Projects[0].Env.Disabled { - t.Errorf("env override lost on upsert: %+v", got.Projects[0].Env) - } -} - -func TestResolveRootDirs_AlwaysReturnsDefaults(t *testing.T) { - got, err := ResolveRootDirs(t.TempDir(), nil) - if err != nil { - t.Fatal(err) - } - if len(got) != 3 || got[0] != "apps" || got[1] != "services" || got[2] != "packages" { - t.Errorf("ResolveRootDirs = %v; want defaults", got) - } -} - -func TestResolveRootDirs_HonorsExplicitOverride(t *testing.T) { - got, err := ResolveRootDirs(t.TempDir(), []string{"frontend", "backend"}) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 || got[0] != "frontend" || got[1] != "backend" { - t.Errorf("ResolveRootDirs override = %v; want [frontend backend]", got) - } -} diff --git a/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go b/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go index fa3a55e..6e3b927 100644 --- a/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go +++ b/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go @@ -66,32 +66,34 @@ func TestSetManifestWorkspaceIdentity_OverwritesPrevious(t *testing.T) { } } -// TestRebuildManifest_PreservesWorkspace is the regression test for -// RebuildManifest accidentally wiping the workspace identity. -// RebuildManifest rewrites the projects list wholesale; Workspace must -// survive. -func TestRebuildManifest_PreservesWorkspace(t *testing.T) { +func TestWriteManifest_PreservesWorkspace(t *testing.T) { tmp := t.TempDir() if err := workspace.SetManifestWorkspaceIdentity(tmp, "demo-abc", "demo"); err != nil { t.Fatal(err) } - if _, err := workspace.RebuildManifest(tmp, []workspace.ManifestProjectInput{ + before, err := workspace.ReadManifest(tmp) + if err != nil { + t.Fatal(err) + } + before.Projects = []workspace.ManifestProject{ {Name: "api", RelativeDir: "services/api", TemplateID: "go-api", Toolchain: "go"}, - }); err != nil { - t.Fatalf("RebuildManifest = %v", err) } + if err := workspace.WriteManifest(tmp, before); err != nil { + t.Fatalf("WriteManifest = %v", err) + } + m, err := workspace.ReadManifest(tmp) if err != nil { t.Fatal(err) } if m.Workspace == nil { - t.Fatalf("RebuildManifest dropped Workspace") + t.Fatalf("WriteManifest dropped Workspace") } if m.Workspace.ID != "demo-abc" || m.Workspace.Name != "demo" { - t.Errorf("workspace mutated by rebuild: %+v", *m.Workspace) + t.Errorf("workspace mutated by write: %+v", *m.Workspace) } if len(m.Projects) != 1 || m.Projects[0].Name != "api" { - t.Errorf("projects not rebuilt: %+v", m.Projects) + t.Errorf("projects not written: %+v", m.Projects) } } diff --git a/packages/cli/internal/core/workspace/manifest_write.go b/packages/cli/internal/core/workspace/manifest_write.go index 7b121ec..b869429 100644 --- a/packages/cli/internal/core/workspace/manifest_write.go +++ b/packages/cli/internal/core/workspace/manifest_write.go @@ -14,17 +14,6 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) -// ManifestProjectInput is the upsert payload from `add` and status fixes. -// PackageManager is optional and absent for Go projects (Go has no -// package manager concept here — go.mod is the single source of truth). -type ManifestProjectInput struct { - Name string - RelativeDir string - TemplateID string - Toolchain string - PackageManager string -} - // EnsureManifest creates an empty manifest if missing and returns whatever // is on disk. func EnsureManifest(projectRoot string) (*Manifest, error) { @@ -38,49 +27,6 @@ func EnsureManifest(projectRoot string) (*Manifest, error) { return ReadManifest(projectRoot) } -// UpsertManifestProject adds or updates the manifest entry for the given -// project (keyed by relativeDir). Preserves the existing per-project -// `domains` override block (env / container / deploy). -func UpsertManifestProject(projectRoot string, input ManifestProjectInput) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - relativeDir := ToPosixPath(input.RelativeDir) - - var preservedEnv *ProjectEnvOverride - var preservedDev *ProjectDevOverride - buildVersion := DefaultBuildVersion - kept := make([]ManifestProject, 0, len(m.Projects)) - for _, p := range m.Projects { - if p.RelativeDir == relativeDir { - preservedEnv = p.Env - preservedDev = p.Dev - buildVersion = NormalizeBuildVersion(p.BuildVersion) - continue - } - kept = append(kept, p) - } - - toolchain := input.Toolchain - if toolchain == "" { - toolchain = "node" - } - kept = append(kept, ManifestProject{ - Name: input.Name, - RelativeDir: relativeDir, - TemplateID: input.TemplateID, - Toolchain: toolchain, - BuildVersion: buildVersion, - PackageManager: input.PackageManager, - Env: preservedEnv, - Dev: preservedDev, - }) - - m.Projects = sortByRelativeDir(kept) - return WriteManifest(projectRoot, m) -} - // MarshalManifest renders the exact bytes WriteManifest publishes: canonical // ordering, 2-space indentation, and a trailing newline. func MarshalManifest(m *Manifest) ([]byte, error) { @@ -105,9 +51,7 @@ func MarshalManifest(m *Manifest) ([]byte, error) { // WriteManifest persists the manifest to disk with 2-space indentation and // a trailing newline (fs-extra parity). Publication is atomic: bytes are // written to a sibling temporary file, synced, closed, and then renamed over -// the destination. Existing file permissions are preserved. Callers that want -// to mutate top-level configuration should use the UpdateManifest* helpers -// below. +// the destination. Existing file permissions are preserved. func WriteManifest(projectRoot string, m *Manifest) error { b, err := MarshalManifest(m) if err != nil { @@ -230,34 +174,6 @@ func InitWorkspaceEnv(projectRoot string, init EnvInit) error { return WriteManifest(projectRoot, m) } -// SetWorkspaceEnvConfig updates only the workspace env backend's config -// blob, preserving Kind. Returns an error when no env backend -// has been selected yet (callers must call InitWorkspaceEnv first). -func SetWorkspaceEnvConfig(projectRoot string, configJSON json.RawMessage) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - if m.Env == nil { - return ErrEnvBackendNotConfigured - } - config := &EnvironmentConfig{} - if err := json.Unmarshal(configJSON, config); err != nil { - return err - } - m.Env = config - return WriteManifest(projectRoot, m) -} - -// ErrEnvBackendNotConfigured is returned by SetWorkspaceEnvConfig when -// the manifest has no env backend selected yet. -var ErrEnvBackendNotConfigured = newErrEnvBackendNotConfigured() - -type envBackendNotConfiguredErr struct{} - -func newErrEnvBackendNotConfigured() error { return envBackendNotConfiguredErr{} } -func (envBackendNotConfiguredErr) Error() string { return "workspace env backend is not configured" } - // EnsureEnvironment guarantees that name is present in // manifest.environments.names. Returns added=true when the environment // list was modified (i.e. name was not already there). Idempotent — @@ -284,7 +200,6 @@ func EnsureEnvironment(projectRoot, name string) (added bool, err error) { // UpdateProjectDev sets projects[].dev.command on the project // entry keyed by relativeDir. Empty cmd clears the override block. -// Mirrors UpdateManifestProjectEnv's read-modify-write pattern. // // Used by creation during `one add` to persist the derived // dev command into the manifest, replacing the legacy Procfile.dev @@ -306,30 +221,10 @@ func UpdateProjectDev(projectRoot, relativeDir, cmd string) error { return nil } -// UpdateManifestProjectEnv sets the env override on a single project -// entry, keyed by relativeDir. Errors if the entry does not exist (use -// UpsertManifestProject first to create it). -func UpdateManifestProjectEnv(projectRoot, relativeDir string, env *ProjectEnvOverride) error { - m, err := ReadManifest(projectRoot) - if err != nil { - return err - } - relativeDir = ToPosixPath(relativeDir) - for i := range m.Projects { - if m.Projects[i].RelativeDir == relativeDir { - m.Projects[i].Env = env - return WriteManifest(projectRoot, m) - } - } - return nil -} - // RecordWorkspaceEnvKey appends key to the workspace-level env config's // keys list (sorted, deduped, idempotent). Use this when a `one env set` // runs at workspace-root scope — i.e. without -p and not inside any -// project. These keys are intended as project-global vars usable by every -// project. Stored inside m.Domains.Env.Config as a plain {"keys": [...]} -// blob alongside any backend-specific fields. +// project. These keys are stored in m.Env.Keys and are usable by every project. func RecordWorkspaceEnvKey(projectRoot, key string) error { if key == "" { return nil @@ -338,7 +233,9 @@ func RecordWorkspaceEnvKey(projectRoot, key string) error { if err != nil { return err } - ensureWorkspaceEnv(m) + if m.Env == nil { + m.Env = &EnvironmentConfig{} + } for _, existing := range m.Env.Keys { if existing == key { return nil @@ -349,16 +246,6 @@ func RecordWorkspaceEnvKey(projectRoot, key string) error { return WriteManifest(projectRoot, m) } -// WorkspaceEnvKeys returns the sorted union of variable names recorded at -// workspace-root scope, or nil when none. Reads the "keys" field embedded -// in m.Domains.Env.Config. -func WorkspaceEnvKeys(m *Manifest) []string { - if m == nil || m.Env == nil { - return nil - } - return m.Env.Keys -} - // RecordProjectEnvKey appends key to projects[i].env.keys for the // named project. Sorted, deduped, idempotent — calling twice with the // same key is a no-op on the second call. Caller passes the project's name @@ -406,61 +293,6 @@ func newEmptyManifestStub() *Manifest { } } -// RebuildManifest replaces the projects array wholesale with the supplied -// inputs, preserving each entry's per-project `domains` override block and -// every workspace-level field. Used by `add` to reconcile drift between -// the filesystem and the manifest. -func RebuildManifest(projectRoot string, inputs []ManifestProjectInput) (*Manifest, error) { - current, err := ReadManifest(projectRoot) - if err != nil { - return nil, err - } - type prior struct { - buildVersion string - env *ProjectEnvOverride - dev *ProjectDevOverride - } - priorByDir := make(map[string]prior, len(current.Projects)) - for _, p := range current.Projects { - priorByDir[p.RelativeDir] = prior{ - buildVersion: NormalizeBuildVersion(p.BuildVersion), - env: p.Env, - dev: p.Dev, - } - } - - projects := make([]ManifestProject, 0, len(inputs)) - for _, in := range inputs { - rel := ToPosixPath(in.RelativeDir) - toolchain := in.Toolchain - if toolchain == "" { - toolchain = "node" - } - p := priorByDir[rel] - projects = append(projects, ManifestProject{ - Name: in.Name, - RelativeDir: rel, - TemplateID: in.TemplateID, - Toolchain: toolchain, - BuildVersion: NormalizeBuildVersion(p.buildVersion), - PackageManager: in.PackageManager, - Env: p.env, - Dev: p.dev, - }) - } - m := &Manifest{ - Version: ManifestVersion, - Workspace: current.Workspace, - Environments: current.Environments, - Env: current.Env, - Projects: sortByRelativeDir(projects), - } - if err := WriteManifest(projectRoot, m); err != nil { - return nil, err - } - return m, nil -} - // SetManifestWorkspaceIdentity writes (or overwrites) the workspace // identity (id / name). Used by `env init` to back-fill the field for // workspaces created before the identity field existed; a fresh diff --git a/packages/cli/internal/core/workspace/overview.go b/packages/cli/internal/core/workspace/overview.go index 58df1fb..faf65f9 100644 --- a/packages/cli/internal/core/workspace/overview.go +++ b/packages/cli/internal/core/workspace/overview.go @@ -171,13 +171,6 @@ func buildProject(m *Manifest, p *ManifestProject) OverviewProject { return out } -func manifestWorkspaceID(m *Manifest) string { - if m == nil || m.Workspace == nil { - return "" - } - return strings.TrimSpace(m.Workspace.ID) -} - func projectResolvedDomains(m *Manifest, p *ManifestProject) map[string]string { out := map[string]string{} if env := EnvBackend(m); env != "" { diff --git a/packages/cli/internal/core/workspace/project_cwd.go b/packages/cli/internal/core/workspace/project_cwd.go deleted file mode 100644 index a28a924..0000000 --- a/packages/cli/internal/core/workspace/project_cwd.go +++ /dev/null @@ -1,132 +0,0 @@ -package workspace - -// project_cwd.go: pure cwd → project lookup. Belongs to the workspace -// package because it composes ResolveRootDirs + DiscoverProjects with a -// path-prefix filter — none of which is secrets-, infra-, or -// product-specific. Originally lived under -// internal/adapters/env/infisical/paths.go for historical reasons; moved here -// so that other callers (environment operations, task commands -// needing cwd-relative project resolution) can call it without depending -// on infisical. - -import ( - "path/filepath" - "strings" -) - -// ResolveProjectFromCWD figures out which project (if any) the user is -// "inside" when running a command without -p / --project. Used so an -// engineer can `cd services/user-api` and run env / run / container -// commands without restating the selector. -// -// Manifest-driven: walks manifest.projects[] and matches cwd against -// each entry's relativeDir. Filesystem discovery is not used here — the -// manifest is the canonical source of truth, and matching against it -// means a project works the moment it's added to the manifest, even -// before any code is in the directory. -// -// Returns nil if cwd is not under any declared project. -func ResolveProjectFromCWD(projectRoot, cwd string) (*Project, error) { - m, err := ReadManifest(projectRoot) - if err != nil { - return nil, err - } - // Resolve symlinks on both ends so macOS /var ↔ /private/var and - // any user-side symlinked workspace dirs don't cause false misses. - cwdResolved := canonicalize(cwd) - rootResolved := canonicalize(projectRoot) - // Pick the deepest match so nested projects (rare but legal) - // resolve to the inner one — `cd apps/web/sub` should pick `web`, - // then if a `web/sub` project also exists, pick that. - var best *ManifestProject - bestDepth := -1 - for i := range m.Projects { - p := &m.Projects[i] - target := filepath.Clean(filepath.Join(rootResolved, filepath.FromSlash(p.RelativeDir))) - if cwdResolved != target && !strings.HasPrefix(cwdResolved, target+string(filepath.Separator)) { - continue - } - depth := strings.Count(p.RelativeDir, "/") - if depth > bestDepth { - bestDepth = depth - best = p - } - } - if best == nil { - return nil, nil - } - return manifestEntryToProject(projectRoot, *best), nil -} - -// canonicalize returns the absolute, symlink-resolved form of path. -// Falls back to filepath.Clean when EvalSymlinks fails (e.g. path -// doesn't exist yet) so callers always get a usable string. -func canonicalize(path string) string { - if resolved, err := filepath.EvalSymlinks(path); err == nil { - return filepath.Clean(resolved) - } - return filepath.Clean(path) -} - -// ResolveProjectFromSelector resolves a pnpm-style selector to a -// project by consulting the manifest. Lookup order: -// -// 1. Exact match against manifest.projects[i].name -// 2. Path-style match against manifest.projects[i].relativeDir -// (tolerates leading `./` and trailing `/` for the pnpm habit -// `pnpm -F ./apps/web`) -// -// Manifest is the source of truth here, not filesystem discovery — -// `one env set -p web` should work the moment the user adds the -// project to the manifest, before any code is in the directory. -// -// Returns nil + nil error when nothing matches; surface that as a -// clear "no such project" error in the CLI layer rather than here. -func ResolveProjectFromSelector(projectRoot, selector string) (*Project, error) { - selector = strings.TrimSpace(selector) - if selector == "" { - return nil, nil - } - m, err := ReadManifest(projectRoot) - if err != nil { - return nil, err - } - pathLike := strings.TrimSuffix(strings.TrimPrefix(selector, "./"), "/") - pathLike = ToPosixPath(pathLike) - for _, p := range m.Projects { - if p.Name == selector { - return manifestEntryToProject(projectRoot, p), nil - } - } - for _, p := range m.Projects { - if p.RelativeDir == pathLike { - return manifestEntryToProject(projectRoot, p), nil - } - } - return nil, nil -} - -func manifestEntryToProject(projectRoot string, p ManifestProject) *Project { - return &Project{ - Name: p.Name, - RelativeDir: p.RelativeDir, - TargetDir: filepath.Join(projectRoot, filepath.FromSlash(p.RelativeDir)), - Toolchain: p.Toolchain, - PackageManager: p.PackageManager, - TemplateID: p.TemplateID, - } -} - -// ProjectNames returns the declared project names from the manifest in -// stable order. Used by the CLI layer when telling the user "no such -// project 'foo' — known names: web / api". -func ProjectNames(m *Manifest) []string { - if m == nil { - return nil - } - out := make([]string, 0, len(m.Projects)) - for _, p := range m.Projects { - out = append(out, p.Name) - } - return out -} diff --git a/packages/cli/internal/core/workspace/projects.go b/packages/cli/internal/core/workspace/projects.go index 2a21cf6..b5e97d7 100644 --- a/packages/cli/internal/core/workspace/projects.go +++ b/packages/cli/internal/core/workspace/projects.go @@ -1,16 +1,6 @@ package workspace -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" -) - -// Project describes one discovered project rooted under a workspace -// root dir. +// Project describes a manifest project and its resolved workspace path. type Project struct { Name string TargetDir string @@ -20,157 +10,6 @@ type Project struct { TemplateID string } -// skipDirs lists directory names that the discovery walk never recurses into. -var skipDirs = map[string]struct{}{ - "node_modules": {}, - ".git": {}, - "dist": {}, - "coverage": {}, - "vendor": {}, -} - -func dirExists(p string) bool { - st, err := os.Stat(p) - return err == nil && st.IsDir() -} - -func fileExists(p string) bool { - _, err := os.Stat(p) - return err == nil -} - -// DiscoverProjects walks the configured root dirs (from ResolveRootDirs) -// and returns every detected project, sorted by RelativeDir for stable -// output. -func DiscoverProjects(projectRoot string, rootDirs []string) ([]Project, error) { - if len(rootDirs) == 0 { - rootDirs = append(rootDirs, DefaultRootDirs...) - } - rootDirs = dedupe(rootDirs) - - var found []Project - for _, rootName := range rootDirs { - root := filepath.Join(projectRoot, rootName) - if !dirExists(root) { - continue - } - dirs, err := collectProjectDirs(root) - if err != nil { - return nil, err - } - for _, target := range dirs { - p, err := classifyProject(projectRoot, target) - if err != nil { - return nil, err - } - found = append(found, p) - } - } - sort.Slice(found, func(i, j int) bool { - return found[i].RelativeDir < found[j].RelativeDir - }) - return found, nil -} - -// collectProjectDirs recursively walks rootDir, returning the closest -// directory that has either package.json or go.mod (one level deep — once -// we find a marker we don't descend into that subtree). -func collectProjectDirs(rootDir string) ([]string, error) { - var out []string - var walk func(string) error - walk = func(dir string) error { - if fileExists(filepath.Join(dir, "package.json")) || fileExists(filepath.Join(dir, "go.mod")) { - out = append(out, dir) - return nil - } - entries, err := os.ReadDir(dir) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return err - } - for _, e := range entries { - if !e.IsDir() { - continue - } - if _, skip := skipDirs[e.Name()]; skip { - continue - } - if err := walk(filepath.Join(dir, e.Name())); err != nil { - return err - } - } - return nil - } - if err := walk(rootDir); err != nil { - return nil, err - } - return out, nil -} - -func classifyProject(projectRoot, targetDir string) (Project, error) { - rel, err := filepath.Rel(projectRoot, targetDir) - if err != nil { - rel = targetDir - } - rel = ToPosixPath(rel) - name := filepath.Base(targetDir) - - if fileExists(filepath.Join(targetDir, "package.json")) { - pkg, err := ReadPackageJSON(targetDir) - if err != nil || pkg == nil { - return Project{}, err - } - manager, err := ResolvePackageManager(projectRoot, pkg.PackageManager) - if err != nil { - return Project{}, err - } - return Project{ - Name: name, - TargetDir: targetDir, - RelativeDir: rel, - Toolchain: "node", - PackageManager: manager, - TemplateID: inferTemplateIDFromPackageJSON(pkg), - }, nil - } - - // Otherwise: go.mod present (collectProjectDirs guaranteed it). - templateID, err := inferTemplateIDFromGoMod(targetDir) - if err != nil { - return Project{}, err - } - return Project{ - Name: name, - TargetDir: targetDir, - RelativeDir: rel, - Toolchain: "go", - TemplateID: templateID, - }, nil -} - -func inferTemplateIDFromPackageJSON(pkg *PackageJSON) string { - deps := mergeDeps(pkg) - switch { - case has(deps, "@nestjs/core") || has(deps, "@nestjs/common"): - return "nestjs-api" - case has(deps, "next"): - return "nextjs-app" - case has(deps, "@astrojs/starlight"): - return "starlight-docs" - case has(deps, "astro"): - return "astro-site" - case has(deps, "expo") || has(deps, "react-native"): - return "expo-mobile" - case has(deps, "tsdown"): - return "ts-library" - case has(deps, "vite") || has(deps, "react"): - return "react-spa" - } - return "custom" -} - func mergeDeps(pkg *PackageJSON) map[string]string { merged := make(map[string]string, len(pkg.Dependencies)+len(pkg.DevDependencies)) for k, v := range pkg.Dependencies { @@ -182,21 +21,16 @@ func mergeDeps(pkg *PackageJSON) map[string]string { return merged } -func has(m map[string]string, key string) bool { - _, ok := m[key] - return ok -} - -func inferTemplateIDFromGoMod(dir string) (string, error) { - raw, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - return "", err +// ProjectNames returns the declared project names from the manifest in +// stable order. Used by the CLI layer when telling the user "no such +// project 'foo' — known names: web / api". +func ProjectNames(m *Manifest) []string { + if m == nil { + return nil } - content := string(raw) - if strings.Contains(content, "github.com/gin-gonic/gin") || - strings.Contains(content, "gorm.io/gorm") || - strings.Contains(content, "gorm.io/driver/postgres") { - return "go-api", nil + out := make([]string, 0, len(m.Projects)) + for _, p := range m.Projects { + out = append(out, p.Name) } - return "custom", nil + return out } diff --git a/packages/cli/internal/core/workspace/roots.go b/packages/cli/internal/core/workspace/roots.go index 825a9ca..41e844d 100644 --- a/packages/cli/internal/core/workspace/roots.go +++ b/packages/cli/internal/core/workspace/roots.go @@ -7,16 +7,8 @@ import ( "path/filepath" ) -// DefaultRootDirs are the directories `discoverProjects` walks when -// scanning a workspace. These are hard-wired: workspaces always -// follow the layout produced by `one create`. (User overrides via -// `manifest.workspace.roots` were dropped from the manifest.) -var DefaultRootDirs = []string{"apps", "services", "packages"} - // PackageJSON captures the slice of package.json that One CLI cares about. -// As of manifest v2 the workspace marker / configuration moved out of -// package.json entirely; this struct only keeps the fields needed for -// dependency-based project classification. +// These fields identify the package manager and dependencies used by project summaries. type PackageJSON struct { Name string `json:"name"` PackageManager string `json:"packageManager,omitempty"` @@ -41,28 +33,3 @@ func ReadPackageJSON(projectRoot string) (*PackageJSON, error) { } return &p, nil } - -// ResolveRootDirs returns the workspace scan-root dirs. The current manifest hard-wires the -// list to DefaultRootDirs; the override parameter is preserved only for -// backwards-compatible call sites passing nil. Non-nil override is honored -// (it lets `--roots` flag work for ad-hoc invocations) but the manifest -// itself never carries a roots override. -func ResolveRootDirs(_ string, override []string) ([]string, error) { - if len(override) > 0 { - return dedupe(override), nil - } - return append([]string{}, DefaultRootDirs...), nil -} - -func dedupe(in []string) []string { - seen := make(map[string]struct{}, len(in)) - out := make([]string, 0, len(in)) - for _, v := range in { - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - return out -} diff --git a/packages/cli/internal/modules/creation/artifacts.go b/packages/cli/internal/modules/creation/artifacts.go index a3ff719..f8c0147 100644 --- a/packages/cli/internal/modules/creation/artifacts.go +++ b/packages/cli/internal/modules/creation/artifacts.go @@ -6,7 +6,6 @@ import ( "io/fs" "os" "path/filepath" - "strings" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" @@ -48,14 +47,6 @@ func syncProject(opts syncProjectOptions) error { return nil } -func backendName(id string) string { - index := strings.IndexByte(id, '/') - if index < 0 || index == len(id)-1 { - return id - } - return id[index+1:] -} - func loadProjectScripts(targetDir string) (map[string]string, error) { raw, err := os.ReadFile(filepath.Join(targetDir, "package.json")) if err != nil { diff --git a/packages/cli/internal/modules/creation/preset.go b/packages/cli/internal/modules/creation/preset.go index 6269db7..40dad18 100644 --- a/packages/cli/internal/modules/creation/preset.go +++ b/packages/cli/internal/modules/creation/preset.go @@ -73,10 +73,8 @@ func ApplyPreset(ctx context.Context, projectRoot string, resolved preset.Resolv // Track template-code occurrence count so duplicate segments // (`fna.fna`) get deterministic project names: nextjs-app, nextjs-app-2, ... - // workspace.UpsertManifestProject's existing dedup is overlaid on - // the filesystem, but the manifest project name and target dir are - // what we hand to materializeProject — and they need to be unique up - // front. + // The manifest project name and target directory handed to + // materializeProject must be unique before writing any files. seenByCode := map[string]int{} customNameIndex := 0 diff --git a/packages/cli/internal/modules/dependencies/service.go b/packages/cli/internal/modules/dependencies/service.go index 320a65a..272a46a 100644 --- a/packages/cli/internal/modules/dependencies/service.go +++ b/packages/cli/internal/modules/dependencies/service.go @@ -380,8 +380,6 @@ func NodeInstallCommand(root, manager string) []string { return []string{"pnpm", "install", "--frozen-lockfile"} } -func exists(path string) bool { _, err := os.Stat(path); return err == nil } - func preparationError(project, dir, command string, err error, detail string) *output.Error { code := cliErrors.ONE_CLI_ERROR var missing *exec.Error diff --git a/packages/cli/internal/modules/preset/codes.go b/packages/cli/internal/modules/preset/codes.go index d70ccbc..006c310 100644 --- a/packages/cli/internal/modules/preset/codes.go +++ b/packages/cli/internal/modules/preset/codes.go @@ -50,9 +50,6 @@ func invertByteMap(m map[byte]string) map[string]byte { // the code is not recognised. func EnvProviderForCode(c byte) string { return envCodes[c] } -// CodeForEnvProvider returns the env code for a provider, or 0 if none. -func CodeForEnvProvider(name string) byte { return envCodeReverse[name] } - // EnvCodesSnapshot returns a stable snapshot of (code, env) pairs. func EnvCodesSnapshot() []CodeEntry { return snapshotByteMap(envCodes) } diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index ac10bfa..6694257 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -69,7 +69,6 @@ "add.kind.library": "Shared library", "add.generating": "Generating technology stack %s", "add.generated": "Project files generated → %s", - "add.syncing": "Updating local development configuration", "add.flag.name": "Project name", "add.flag.yes": "Run non-interactively", "locale_success": "✓ Display language set to %s", @@ -92,13 +91,11 @@ "create.generating": "Creating workspace", "create.generated": "Workspace files generated → %s", "create.registry_warning": "Workspace created, but the local workspace list was not updated (%v); `one serve` in that directory will try again", - "create.binding_env": "Connecting the environment-variable service", "create.flag.name": "Workspace name (default: target directory name)", "create.flag.yes": "Use defaults without prompting", "create.flag.preset": "Automation preset ID; implies --yes and may create projects", "create.flag.project_names": "Comma-separated project names for --preset expansion", "common.next_steps": "Next steps:", - "common.optional": "Optional:", "workspace.title": "Workspace: %s", "workspace.projects": "Projects:", "workspace.no_projects": " No projects yet", @@ -118,12 +115,10 @@ "hooks.check_failed_hint": "Run `one hk fix` from the workspace root to apply available fixes, then review and stage the changes with `git add` before retrying. hk selects each project's directory and tools automatically.", "create.hooks_warning": "Workspace created, but Git hooks need setup (%v); run one init hooks after resolving it", "mise.tip": "Optional access to mise for configuration trust, diagnostics, and tool installation. One first uses a compatible mise on PATH. Otherwise it reuses or downloads a verified, pinned runtime into its own directories; first use without a local runtime requires network access. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one exec.", - "common.install_continue": "Install and continue", "common.cancel": "Cancel", "common.cancelled": "Operation cancelled.", "env.short": "Manage environment variables", "env.tip": "Shows the environment-variable source and scope, or sets, deletes, and lists variables. list shows names only. Secret values are never displayed; use one exec to inject them into a command.", - "env.workspace_required": "Manage environment variables from inside a One CLI workspace.", "env.summary_source": "Environment-variable source: %s", "env.summary_default": "Default environment: %s", "env.summary_available": "Available environments: %s", @@ -142,7 +137,6 @@ "env.set.tip": "Sets one environment variable. Use `one env set KEY` for hidden interactive input, or pass `KEY VALUE` / `KEY=VALUE` in automation.", "env.flag.project": "Project name or relative path; defaults to the current project", "env.flag.environment": "Environment name (default: workspace default)", - "env.flag.profile": "Use a named local connection for this run", "env.flag.yes": "Confirm overwrites and new environments non-interactively", "env.set.short": "Write a variable to Infisical", "env.list.short": "List environment-variable names", @@ -165,7 +159,6 @@ "templates.list.short": "List available technology stacks", "locale.short": "Show or set the display language (auto / zh-CN / en-US)", "env.key_required": "The argument is required.", - "file.read_failed": "Could not read %s: %v", "infisical.network": "Cannot connect to Infisical. Check your network connection.", "infisical.relogin": "The Infisical session is no longer valid. Run one logout, then one login.", "infisical.folder_missing": "Infisical folder not found (environment=%s, folder=%s).", @@ -199,14 +192,6 @@ "global.folder_invalid": "The folder name is invalid.", "global.scope_required": "Using shared credentials requires explicit --env and --path values.", "global.key_invalid": "The variable name is invalid.", - "infisical.init.success": "✓ Secrets configuration written", - "infisical.init.project_created": " Project: %s (%s) — created\n", - "infisical.init.project_named": " Project: %s (%s)\n", - "infisical.init.project": " Project: %s\n", - "infisical.init.environments": " Environments: %s (default: %s)\n", - "infisical.init.path": " Root path: %s\n", - "infisical.init.auth": " Auth: %s\n", - "infisical.init.written": " Written to: %s\n", "workspace.manifest_required": "No one.manifest.json was found. Run this command in a One workspace.", "infisical.init.verify_conflict": "--skip-verify cannot be combined with automatic project creation. Pass --project-id or remove --skip-verify.", "infisical.init.name_required": "Cannot infer an Infisical project name from the manifest or package.json. Pass --project-name or --project-id.", @@ -344,12 +329,6 @@ "registry.field_missing": "%s missing", "template.local_missing": "Local template %s was not found. Check that templates/%s exists.", "template.render_failed": "Could not render template %s: %v", - "backend.id_invalid": "invalid id %q: expected /", - "backend.selection_conflict": "two selections for domain %q: %q and %q", - "workspace.project_missing": "project %q not found in manifest", - "workspace.package_object": "package.json root is not an object", - "workspace.package_key": "non-string key in package.json", - "workspace.script_key": "non-string key inside scripts", "manifest.parse_failed": "Could not parse one.manifest.json.", "manifest.retired_fields": "Container and deploy features have been removed. Delete their configuration fields from one.manifest.json and retry.", "manifest.version_unsupported": "Manifest version %d is unsupported; this CLI supports v%d.", @@ -365,7 +344,6 @@ "creation.project_package_object": "project package.json must be an object", "creation.root_package_object": "root package.json must be an object", "creation.workspaces_invalid": "invalid package.json workspaces", - "creation.workspace_packages_invalid": "invalid package.json workspaces.packages: %w", "creation.workspace_exclusion": "cannot safely add %s with workspace exclusion %q; update that pattern first", "creation.project_excluded": "project %s is excluded by workspace pattern %q", "creation.pnpm_mapping": "pnpm-workspace.yaml must contain a mapping", @@ -400,9 +378,6 @@ "env.backend_unknown": "Unsupported environment-variable source %q.", "env.catalog_required": "modules: environment catalog is required", "env.operation_unsupported": "The %s source does not support one env %s.", - "env.backend_invalid": "Unsupported environment source %q. Only Infisical is supported.", - "env.backend_unchanged": "This workspace already uses %s; no switch is needed.", - "env.migrate_conflicts": "%d variables already exist in Infisical with different values. Rerun with --overwrite to replace them.", "file.read_error": "read %s: %w", "env.infisical_required": "This workspace does not use Infisical.", "gowork.module_missing": "missing go.mod in workspace member %s (module moved or removed)", @@ -438,7 +413,6 @@ "preset.template_not_registered": "template code %q is not registered", "preset.env_not_registered": "env code %q is not registered", "preset.category_mismatch": "template %s is %s; cannot be used as %s segment", - "path.absolute_directory_required": "%s must be an absolute directory", "file.plan_conflict": "%s changed between workspace plans; retry", "file.prepare_conflict": "%s changed while preparing the workspace; retry", "file.restore_conflict": "cannot restore concurrently modified %s", @@ -463,8 +437,6 @@ "auth.token_expired": "The Infisical user token is invalid or expired.", "preferences.missing": "preferences: nil", "preferences.locale_invalid": "preferences: invalid locale; expected auto | zh-CN | en-US", - "process.command_empty": "RunExternal: empty argv", - "process.binary_missing": "The %s executable was not found on PATH.", "update.available": "⚠ A new One CLI version is available: %s (current: %s)", "path.home_empty": "user home directory is empty", "runtime.unknown": "Unknown runtime %q; use builtin or mise.", @@ -613,7 +585,6 @@ "hooks.custom_directory": "Existing hook directory %s is user-managed; preserve it and integrate its checks before changing the hook setup", "dependencies.failed_summary": "%s dependency preparation failed (%s): %v", "workspace.node_member_pattern": "Project %q has unsupported workspace pattern %q. Use relative package paths or single-level globs.", - "creation.composite_requires_pnpm": "Template %q contains multiple Node packages and requires a pnpm workspace; the current workspace uses %q.", "creation.duplicate_package": "Package name %q is shared by %q and %q. Choose a different project name.", "serve.project_payload_invalid": "Invalid project creation request: %v", "tasks.dynamic_taskfile": "Taskfile %s cannot be discovered statically. Define the task explicitly in mise.toml.", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 5a16081..798f222 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -69,7 +69,6 @@ "add.kind.library": "共享库", "add.generating": "正在生成技术栈 %s", "add.generated": "项目文件已生成 → %s", - "add.syncing": "正在更新本地开发配置", "add.flag.name": "项目名称", "add.flag.yes": "非交互运行", "locale_success": "✓ 显示语言已设置为 %s", @@ -92,13 +91,11 @@ "create.generating": "正在创建工作区", "create.generated": "工作区文件已生成 → %s", "create.registry_warning": "工作区已创建,但本机工作区列表未更新(%v);在该目录运行 `one serve` 时会再次登记", - "create.binding_env": "正在连接环境变量服务", "create.flag.name": "工作区名称(默认使用目标目录名)", "create.flag.yes": "不询问并使用默认值", "create.flag.preset": "自动化 preset ID;隐含 --yes,且可创建项目", "create.flag.project_names": "--preset 展开时使用的逗号分隔项目名", "common.next_steps": "下一步:", - "common.optional": "可选:", "workspace.title": "工作区:%s", "workspace.projects": "项目:", "workspace.no_projects": " 暂无项目", @@ -118,12 +115,10 @@ "hooks.check_failed_hint": "请在工作区根目录运行 `one hk fix` 修复可自动处理的问题,检查改动并用 `git add` 重新暂存后再重试。hk 会自动选择各项目的目录和工具。", "create.hooks_warning": "工作区已创建,但 Git hooks 尚未安装(%v);解决后运行 one init hooks", "mise.tip": "按需访问 mise 的配置信任、诊断和工具安装功能。One 优先使用 PATH 中兼容的 mise,否则复用或下载校验过的固定版本并放入自己的目录;本地没有可用版本时首次使用需要联网。参数和输出直接透传,此入口不加载 One 的项目密钥。日常开发继续使用 one dev 和 one exec。", - "common.install_continue": "安装并继续", "common.cancel": "取消", "common.cancelled": "操作已取消。", "env.short": "管理环境变量", "env.tip": "查看环境变量来源和作用域,或写入、删除和列出变量。list 只显示名称。命令行不展示密钥值;通过 one exec 将其注入命令。", - "env.workspace_required": "请在 One CLI 工作区内管理环境变量。", "env.summary_source": "环境变量来源:%s", "env.summary_default": "默认环境:%s", "env.summary_available": "可用环境:%s", @@ -142,7 +137,6 @@ "env.set.tip": "设置一个环境变量。使用 `one env set KEY` 可在交互终端中隐藏输入;自动化可传 `KEY VALUE` 或 `KEY=VALUE`。", "env.flag.project": "项目名称或相对路径;默认使用当前项目", "env.flag.environment": "环境名称(默认使用工作区默认环境)", - "env.flag.profile": "本次使用指定名称的本机连接", "env.flag.yes": "非交互确认覆盖和创建新环境", "env.set.short": "向 Infisical 写入一个环境变量", "env.list.short": "列出所有 KEY", @@ -165,7 +159,6 @@ "templates.list.short": "查看可用技术栈", "locale.short": "查看或设置显示语言(auto / zh-CN / en-US)", "env.key_required": "必须提供 位置参数。", - "file.read_failed": "读取 %s 失败:%v", "infisical.network": "无法连接 Infisical,请检查网络。", "infisical.relogin": "Infisical 登录失效,请先运行 one logout,再运行 one login。", "infisical.folder_missing": "Infisical 目录不存在(环境=%s,目录=%s)。", @@ -199,14 +192,6 @@ "global.folder_invalid": "目录名无效", "global.scope_required": "使用全局凭据必须显式指定 --env 和 --path", "global.key_invalid": "变量名无效", - "infisical.init.success": "✓ 密钥配置已写入", - "infisical.init.project_created": " 项目:%s(%s)— 已创建\n", - "infisical.init.project_named": " 项目:%s(%s)\n", - "infisical.init.project": " 项目:%s\n", - "infisical.init.environments": " 环境:%s(默认:%s)\n", - "infisical.init.path": " 根目录:%s\n", - "infisical.init.auth": " 身份验证:%s\n", - "infisical.init.written": " 写入位置:%s\n", "workspace.manifest_required": "未找到 one.manifest.json。请先在 One workspace 根目录执行。", "infisical.init.verify_conflict": "--skip-verify 与自动创建项目互斥。请显式传 --project-id,或去掉 --skip-verify。", "infisical.init.name_required": "无法推断 Infisical 项目名(manifest.project.name / package.json#name 都为空)。请显式传 --project-name 或 --project-id。", @@ -344,12 +329,6 @@ "registry.field_missing": "缺少 %s", "template.local_missing": "本地模板不存在:%s。请确认 templates/%s 已存在。", "template.render_failed": "模板渲染失败 %s: %v", - "backend.id_invalid": "无效的标识 %q:应为 /", - "backend.selection_conflict": "%q 配置了两个选项:%q 和 %q", - "workspace.project_missing": "manifest 中不存在项目 %q", - "workspace.package_object": "package.json 的根必须是对象", - "workspace.package_key": "package.json 的键必须是字符串", - "workspace.script_key": "scripts 的键必须是字符串", "manifest.parse_failed": "one.manifest.json 解析失败。", "manifest.retired_fields": "container 和 deploy 功能已下线,请手动删除 one.manifest.json 中对应的配置字段后重试。", "manifest.version_unsupported": "one.manifest.json 版本 %d 不支持,当前 CLI 仅认 v%d。", @@ -365,7 +344,6 @@ "creation.project_package_object": "项目 package.json 必须是对象", "creation.root_package_object": "根 package.json 必须是对象", "creation.workspaces_invalid": "package.json 中的 workspaces 无效", - "creation.workspace_packages_invalid": "package.json 中的 workspaces.packages 无效:%w", "creation.workspace_exclusion": "无法安全添加 %s,因为存在排除规则 %q;请先修改该规则", "creation.project_excluded": "项目 %s 被工作区规则 %q 排除", "creation.pnpm_mapping": "pnpm-workspace.yaml 必须包含映射", @@ -400,9 +378,6 @@ "env.backend_unknown": "不支持的 env backend %q", "env.catalog_required": "需要环境变量服务目录", "env.operation_unsupported": "%s 后端不支持 `one env %s`。", - "env.backend_invalid": "不支持环境变量来源 %q;当前仅支持 Infisical。", - "env.backend_unchanged": "工作区已经是 %s 后端,无需切换。", - "env.migrate_conflicts": "%d 个 key 在 Infisical 已存在且值不同;加 --overwrite 重跑以覆盖。", "file.read_error": "读取 %s 失败:%w", "env.infisical_required": "当前工作区没有选择 Infisical backend。", "gowork.module_missing": "工作区成员 %s 缺少 go.mod(模块可能已移动或删除)", @@ -438,7 +413,6 @@ "preset.template_not_registered": "模板编码 %q 未注册", "preset.env_not_registered": "环境编码 %q 未注册", "preset.category_mismatch": "模板 %s 的类型为 %s,不能用于 %s 段", - "path.absolute_directory_required": "%s 必须是绝对目录", "file.plan_conflict": "%s 在工作区计划之间发生了变化,请重试", "file.prepare_conflict": "准备工作区时 %s 发生了变化,请重试", "file.restore_conflict": "无法恢复被同时修改的 %s", @@ -463,8 +437,6 @@ "auth.token_expired": "Infisical 用户登录令牌无效或已过期", "preferences.missing": "缺少偏好设置", "preferences.locale_invalid": "无效的语言偏好;可选 auto、zh-CN 或 en-US", - "process.command_empty": "执行命令不能为空", - "process.binary_missing": "%s 二进制不在 PATH 中", "update.available": "⚠ one cli 有新版本可用:%s(当前 %s)", "path.home_empty": "用户主目录为空", "runtime.unknown": "未知运行时 %q;可选 builtin 或 mise。", @@ -613,7 +585,6 @@ "hooks.custom_directory": "已有钩子目录 %s 由用户管理;请保留它并整合其检查后再修改钩子配置", "dependencies.failed_summary": "%s 的依赖准备失败(%s):%v", "workspace.node_member_pattern": "项目 %q 中的 workspace 路径模式 %q 不受支持。请使用相对包路径或单层通配符。", - "creation.composite_requires_pnpm": "模板 %q 包含多个 Node 包,需要 pnpm 工作区;当前工作区使用 %q。", "creation.duplicate_package": "包名 %q 同时用于 %q 和 %q,请选择不同的项目名。", "serve.project_payload_invalid": "新建项目请求无效:%v", "tasks.dynamic_taskfile": "无法静态发现 Taskfile %s,请在 mise.toml 中显式定义任务。", diff --git a/packages/cli/internal/platform/output/mode.go b/packages/cli/internal/platform/output/mode.go index 61f0573..f5e0631 100644 --- a/packages/cli/internal/platform/output/mode.go +++ b/packages/cli/internal/platform/output/mode.go @@ -61,9 +61,6 @@ func resolve() resolved { // IsJSON reports whether the active output is JSON specifically. func IsJSON() bool { return resolve() == resolvedJSON } -// IsYAML reports whether the active output is YAML. -func IsYAML() bool { return resolve() == resolvedYAML } - // IsTTY reports whether the active output is human-friendly TTY. func IsTTY() bool { return resolve() == resolvedTTY } diff --git a/packages/cli/internal/platform/process/external.go b/packages/cli/internal/platform/process/external.go deleted file mode 100644 index 37096a2..0000000 --- a/packages/cli/internal/platform/process/external.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package process contains shared helpers for invoking child processes. -package process - -import ( - "os" - "os/exec" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -// RunExternal forwards stdin, stdout, and stderr to an external process. -// It checks binary availability first so callers receive a structured error -// with a useful remediation hint. -func RunExternal(workdir string, args []string, missingHint string) error { - if len(args) == 0 { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, i18n.T("process.command_empty")) - } - if _, err := exec.LookPath(args[0]); err != nil { - msg := i18n.Tf("process.binary_missing", args[0]) - if missingHint != "" { - msg += ";" + missingHint - } - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, msg) - } - cmd := Command(args[0], args[1:]...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Dir = workdir - return cmd.Run() -} diff --git a/packages/cli/internal/platform/prompt/prompt.go b/packages/cli/internal/platform/prompt/prompt.go index fe5ee63..cb3e02f 100644 --- a/packages/cli/internal/platform/prompt/prompt.go +++ b/packages/cli/internal/platform/prompt/prompt.go @@ -110,32 +110,6 @@ func Select[T comparable](title string, options []Option[T]) (T, error) { return value, nil } -// MultiSelect lets the user toggle multiple options. preSelected values -// are checked when the prompt opens; pass nil for "nothing pre-checked". -// -// Description, when present on an option, is appended to the label as -// "