= ({ 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 (
-
-
-
-
-
-
- {backend || t("projects.matrix.notConfigured")}
-
-
- {issue?.reason === "profile"
- ? t("projects.matrix.profileMissing")
- : issue
- ? t("projects.matrix.setupRequired")
- : t("projects.matrix.ready")}
-
-
-
- );
-};
-
-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")}
-
-
setFiltersOpen((open) => !open)}
- aria-expanded={filtersOpen}
- >
-
- {filtersOpen ? t("projects.hideFilters") : t("projects.showFilters")}
-
-
-
- {filtersOpen ? (
-
-
- setStatus(value as typeof status)}>
-
-
-
-
- {t("projects.statuses.all")}
- {t("projects.statuses.attention")}
- {t("projects.statuses.healthy")}
-
-
-
-
-
-
-
- setQuery(event.target.value)}
- placeholder={t("projects.search")}
- aria-label={t("projects.search")}
- className="text-xs"
- />
-
-
- setKind(value as typeof kind)}>
-
-
-
-
- {t("projects.kinds.all")}
- {t("overview.kinds.app")}
- {t("overview.kinds.service")}
- {t("overview.kinds.package")}
-
-
-
-
- ) : 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, "overview")}
- disabled={readOnly}
- className="group h-auto w-full justify-start gap-3 rounded-none p-0 text-left hover:bg-transparent disabled:cursor-default"
- >
-
-
-
-
-
- {project.name}
-
-
- {project.relativeDir}
-
-
-
-
-
- onInspect(project, "environment")}
- />
-
-
- {issueCount > 0 ? (
- {
- const issue = project.issues?.[0];
- if (issue) onInspect(project, "environment");
- }}
- >
-
- {t("projects.matrix.issueCount", { count: issueCount })}
-
- ) : (
-
-
- {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
);
-export const FormLayout: React.FC<
- React.PropsWithChildren<{
- title: string;
- }>
-> = ({ title, 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) => (
-
- ))}
-
- {projectActions.map(({ project, issue }) => (
-
-
-
-
-
-
- {project.name}
- {project.relativeDir}
-
-
{issueMessage(issue)}
-
-
onInspect(project, "environment")}
- >
- {t("overview.actionCenter.resolve")}
-
-
-
- ))}
-
- 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("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
-// " — ". Same encoding rationale as
-// SelectWithDescriptions: keep accessible-mode output coherent.
-func MultiSelect[T comparable](title string, options []Option[T], preSelected []T) ([]T, error) {
- values := append([]T{}, preSelected...)
- huhOpts := make([]huh.Option[T], 0, len(options))
- for _, opt := range options {
- label := opt.Label
- if opt.Description != "" {
- label = fmt.Sprintf("%s — %s", opt.Label, opt.Description)
- }
- huhOpts = append(huhOpts, huh.NewOption(label, opt.Value))
- }
- field := huh.NewMultiSelect[T]().
- Title(title).
- Options(huhOpts...).
- Value(&values)
- if err := runField(field); err != nil {
- return nil, mapErr(err)
- }
- return values, nil
-}
-
// SelectWithDescriptions is like Select but renders a per-option description
// line below each entry. Useful for template pickers where the user needs
// to read what each option actually does.
diff --git a/packages/cli/internal/transport/cobra/env/set.go b/packages/cli/internal/transport/cobra/env/set.go
index ec1e61b..5da2b87 100644
--- a/packages/cli/internal/transport/cobra/env/set.go
+++ b/packages/cli/internal/transport/cobra/env/set.go
@@ -172,12 +172,3 @@ func confirmCreateEnv(name string, yes bool) error {
}
return nil
}
-
-func contains(values []string, target string) bool {
- for _, value := range values {
- if value == target {
- return true
- }
- }
- return false
-}
diff --git a/packages/cli/internal/transport/http/handlers_workspace_mutate.go b/packages/cli/internal/transport/http/handlers_workspace_mutate.go
index 3aaf4a4..27f34b9 100644
--- a/packages/cli/internal/transport/http/handlers_workspace_mutate.go
+++ b/packages/cli/internal/transport/http/handlers_workspace_mutate.go
@@ -7,7 +7,6 @@ import (
"github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution"
manifestapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/manifest"
workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace"
- workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace"
cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors"
)
@@ -143,9 +142,5 @@ func writeNotFound(w http.ResponseWriter, message string) {
func writeManifestErr(w http.ResponseWriter, err error) {
message := err.Error()
- if errors.Is(err, workspacecore.ErrEnvBackendNotConfigured) {
- writeError(w, http.StatusConflict, cliErrors.ONE_CLI_ERROR, message, nil)
- return
- }
writeError(w, http.StatusInternalServerError, cliErrors.MANIFEST_INVALID, message, nil)
}
diff --git a/packages/cli/tests/e2e/e2e_helpers_test.go b/packages/cli/tests/e2e/e2e_helpers_test.go
index f5fde8f..6ce5960 100644
--- a/packages/cli/tests/e2e/e2e_helpers_test.go
+++ b/packages/cli/tests/e2e/e2e_helpers_test.go
@@ -303,13 +303,6 @@ func readManifest(t *testing.T, workspaceRoot string) map[string]any {
return mustParseJSON(t, string(raw))
}
-// jsonContains reports whether stdout looks like a JSON object that
-// contains the given top-level key. Cheap pre-check before mustParseJSON
-// when the binary may have printed nothing.
-func jsonContains(s, key string) bool {
- return strings.Contains(s, "\""+key+"\":")
-}
-
// bootstrapWorkspace runs `one create / -y` to produce a
// fresh workspace under HOME-isolated tempdir, returning the workspace
// root. The caller is expected to have already called isolateHome(t, tmp).
diff --git a/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go b/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go
index 910f94b..5114311 100644
--- a/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go
+++ b/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go
@@ -114,33 +114,6 @@ func waitForTTYOutput(t *testing.T, output *lockedBuffer, want string, timeout t
t.Fatalf("timed out waiting for %q in PTY output:\n%s", want, output.String())
}
-func waitForTTYPrompt(t *testing.T, output *lockedBuffer, terminal io.Writer, want string, timeout time.Duration) {
- t.Helper()
- deadline := time.Now().Add(timeout)
- backgroundReplied := false
- cursorReplied := false
- for time.Now().Before(deadline) {
- got := output.String()
- if strings.Contains(got, want) {
- return
- }
- if !backgroundReplied && strings.Contains(got, "\x1b]11;?") {
- if _, err := terminal.Write([]byte("\x1b]11;rgb:0000/0000/0000\x1b\\")); err != nil {
- t.Fatalf("reply to terminal background query: %v", err)
- }
- backgroundReplied = true
- }
- if !cursorReplied && strings.Contains(got, "\x1b[6n") {
- if _, err := terminal.Write([]byte("\x1b[1;1R")); err != nil {
- t.Fatalf("reply to terminal cursor query: %v", err)
- }
- cursorReplied = true
- }
- time.Sleep(10 * time.Millisecond)
- }
- t.Fatalf("timed out waiting for %q in PTY output:\n%s", want, output.String())
-}
-
func replaceEnvValues(env []string, values map[string]string) []string {
result := make([]string, 0, len(env)+len(values))
for _, kv := range env {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5bc1248..249ba78 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -80,9 +80,6 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
- globals:
- specifier: ^17.12.0
- version: 17.12.0
jsdom:
specifier: ^30.1.1
version: 30.1.1(@noble/hashes@1.8.0)
@@ -3166,10 +3163,6 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
- globals@17.12.0:
- resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==}
- engines: {node: '>=18'}
-
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -7653,8 +7646,6 @@ snapshots:
dependencies:
is-glob: 4.0.3
- globals@17.12.0: {}
-
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
From 23babc9bbdb7baab3090a031a2bb8aea1becb89c Mon Sep 17 00:00:00 2001
From: caorushizi <84996057@qq.com>
Date: Tue, 29 Sep 2026 00:59:32 +0800
Subject: [PATCH 11/16] fix(cli): restore concise workspace agent guidance
---
apps/docs/content/docs/en/create.md | 2 +-
apps/docs/content/docs/zh/create.md | 2 +-
.../modules/creation/workspace_content.go | 28 ++++++++++++++-----
3 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md
index cdbbfcb..352b4a2 100644
--- a/apps/docs/content/docs/en/create.md
+++ b/apps/docs/content/docs/en/create.md
@@ -125,4 +125,4 @@ Full table: [Error codes](/en/docs/error-codes/).
## Agent instructions
-New workspaces include bilingual `AGENTS.md` guidance for `one run`, `one exec`, pnpm, and Task. Later project additions preserve team edits.
+New workspaces include a concise bilingual `AGENTS.md` with 7 rules per language covering workspace discovery, CLI help, adding projects, tasks and verification, dependencies, environment variables, and automation error handling. Command details are discovered through `one --help` instead of embedding a full command manual. The file is team-owned; later `one add` operations preserve edits.
diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md
index e02d97b..7ed014d 100644
--- a/apps/docs/content/docs/zh/create.md
+++ b/apps/docs/content/docs/zh/create.md
@@ -131,4 +131,4 @@ one dev -p api
## Agent 指引
-新工作区生成中英文 `AGENTS.md`,说明 `one run`、`one exec`、pnpm 和 Task 的分工。后续添加项目保留团队修改。
+新工作区生成精简的中英文 `AGENTS.md`,各用 7 条规则覆盖工作区定位、CLI 帮助、添加项目、任务与验证、依赖、环境变量和自动化错误处理。命令细节通过 `one --help` 按需查询,不内嵌完整命令手册。该文件由团队维护,后续 `one add` 保留修改。
diff --git a/packages/cli/internal/modules/creation/workspace_content.go b/packages/cli/internal/modules/creation/workspace_content.go
index 735a898..5340b69 100644
--- a/packages/cli/internal/modules/creation/workspace_content.go
+++ b/packages/cli/internal/modules/creation/workspace_content.go
@@ -6,15 +6,29 @@ import (
// These are the verbatim file contents the scaffolder writes.
-const agentsContent = `# Development / 开发
+const agentsContent = `# One CLI workspace / 工作区
-Use one run to list and execute workspace tasks.
-Use one exec -- for commands with project environment variables.
-Node projects use pnpm; Go projects use Task.
+## English
-使用 one run 列出和执行工作区任务。
-使用 one exec -- 在项目环境中运行命令。
-Node 项目使用 pnpm,Go 项目使用 Task。
+- **Workspace:** Find the nearest one.manifest.json in the current directory or its ancestors; read it for project names and paths. apps/ holds applications, services/ backends and workers, packages/ shared libraries.
+- **CLI:** Use One for supported workspace operations. Start with one --help, then one COMMAND --help; use one help --all for the full catalogue. Current help is authoritative; do not guess commands or flags.
+- **Projects:** Discover template IDs with one templates -o json, then use one add TEMPLATE --name NAME --yes. Supply explicit inputs for non-interactive operations.
+- **Tasks:** List with one run; execute with one run TASK -p PROJECT. Node commands live in package.json scripts (pnpm), Go commands in Taskfile (Task); workspace orchestration and tool versions live in root mise.toml. Run the relevant existing checks, tests and builds after changes.
+- **Dependencies:** If missing, install Node dependencies once at the workspace root with pnpm install; use go mod download in each affected Go module. Use go mod tidy when imports change or module files need repair.
+- **Environment:** Use one exec PROJECT -- COMMAND for ad hoc commands with project variables. Bound projects receive Infisical variables; One does not load local .env files. Application code consumes environment variables; do not write secret values into source or generated config.
+- **Automation:** Request -o json for structured One output; branch on error.code and use error.context for recovery, rather than parsing translated messages. Preserve child-process logs as logs.
+
+## 中文
+
+- **工作区:** 从当前目录向上查找最近的 one.manifest.json,读取项目名称与路径。apps/ 放应用,services/ 放后端与 worker,packages/ 放共享库。
+- **CLI:** 工作区操作优先使用 One。先看 one --help,再看 one COMMAND --help;完整目录用 one help --all。以当前帮助为准,不猜测命令或参数。
+- **项目:** 用 one templates -o json 查询模板 ID,再用 one add TEMPLATE --name NAME --yes 添加项目。非交互操作显式提供所需输入。
+- **任务:** 用 one run 列出任务,用 one run TASK -p PROJECT 执行。Node 命令写在 package.json scripts 中,由 pnpm 执行;Go 命令写在 Taskfile 中,由 Task 执行;工作区编排和工具版本放在根 mise.toml。修改后运行相关的现有检查、测试与构建。
+- **依赖:** 缺失时在工作区根目录执行一次 pnpm install;Go 在受影响模块目录执行 go mod download。修改导入或修复模块文件时使用 go mod tidy。
+- **环境:** 临时命令使用 one exec PROJECT -- COMMAND 加载项目变量。已绑定项目从 Infisical 注入变量;One 不读取本地 .env。业务代码读取环境变量,不把密钥值写入源码或生成的配置。
+- **自动化:** One 的结构化输出使用 -o json;按 error.code 判断错误,结合 error.context 恢复,不解析翻译后的消息。子进程日志仍按日志处理。
+
+This file is team-owned; one add preserves edits. / 本文件由团队维护,one add 会保留修改。
`
const pnpmWorkspaceContent = `packages:
From 61fbbccf4d1e790da1a2eccc65bc4a5c70063071 Mon Sep 17 00:00:00 2001
From: caorushizi <84996057@qq.com>
Date: Tue, 29 Sep 2026 01:40:05 +0800
Subject: [PATCH 12/16] feat: support empty application, service, and library
projects
---
apps/dashboard/src/api/project-creation.ts | 2 +-
.../CreateProjectDialog.test.tsx | 31 +++++--
apps/dashboard/src/locales/en-US.json | 38 ++++++++-
apps/dashboard/src/locales/zh-CN.json | 38 ++++++++-
.../src/pages/ProjectTemplates.test.tsx | 4 +-
apps/dashboard/src/pages/ProjectTemplates.tsx | 7 +-
apps/docs/content/docs/en/add.md | 16 +++-
apps/docs/content/docs/zh/add.md | 16 +++-
.../cli/internal/core/template/registry.go | 3 +
.../cli/internal/modules/creation/project.go | 4 +-
.../internal/platform/i18n/locales/en-US.json | 13 ++-
.../internal/platform/i18n/locales/zh-CN.json | 13 ++-
.../cli/internal/transport/cobra/add/cmd.go | 6 +-
.../http/handlers_project_create_test.go | 44 ++++++++++
packages/cli/testdata/preset/v1_codes.json | 12 +++
packages/cli/testdata/reference/help/add.txt | 4 +-
.../cli/testdata/reference/templates.json | 41 ++++++++-
packages/cli/tests/e2e/empty_project_test.go | 84 +++++++++++++++++++
packages/templates/empty-app/.gitkeep | 0
packages/templates/empty-library/.gitkeep | 0
packages/templates/empty-service/.gitkeep | 0
packages/templates/registry.json | 42 ++++++++++
22 files changed, 390 insertions(+), 28 deletions(-)
create mode 100644 packages/cli/tests/e2e/empty_project_test.go
create mode 100644 packages/templates/empty-app/.gitkeep
create mode 100644 packages/templates/empty-library/.gitkeep
create mode 100644 packages/templates/empty-service/.gitkeep
diff --git a/apps/dashboard/src/api/project-creation.ts b/apps/dashboard/src/api/project-creation.ts
index 10aaebb..082c7f4 100644
--- a/apps/dashboard/src/api/project-creation.ts
+++ b/apps/dashboard/src/api/project-creation.ts
@@ -7,7 +7,7 @@ export interface ProjectTemplate {
description: string;
category: "frontend" | "backend" | "library";
directory: string;
- toolchain: "node" | "go";
+ toolchain: "node" | "go" | "none";
}
export interface CreatedProject {
diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx
index cd87bb5..ce04672 100644
--- a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx
+++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx
@@ -7,7 +7,19 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog";
import i18n from "@/lib/i18n";
+const emptyTemplates = [
+ { id: "empty-app", category: "frontend", directory: "apps" },
+ { id: "empty-service", category: "backend", directory: "services" },
+ { id: "empty-library", category: "library", directory: "packages" },
+].map((template) => ({
+ ...template,
+ name: template.id,
+ description: template.id,
+ toolchain: "none",
+}));
+
const templates = [
+ ...emptyTemplates,
{
id: "react-spa",
name: "React",
@@ -55,7 +67,14 @@ afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("project creation", () => {
- it.each(["en-US", "zh-CN"])("creates the selected template in %s", async (locale) => {
+ it.each(
+ ["en-US", "zh-CN"].flatMap((locale) =>
+ [...emptyTemplates, { id: "go-api", directory: "services" }].map((template) => ({
+ locale,
+ ...template,
+ })),
+ ),
+ )("creates $id in $locale", async ({ locale, id, directory }) => {
await i18n.changeLanguage(locale);
const user = userEvent.setup();
let payload: unknown;
@@ -66,7 +85,7 @@ describe("project creation", () => {
expect(params.entryId).toBe("selected-workspace");
payload = await request.json();
return HttpResponse.json(
- { name: "api", relativeDir: "services/api", templateId: "go-api" },
+ { name: "api", relativeDir: `${directory}/api`, templateId: id },
{ status: 201 },
);
},
@@ -78,12 +97,12 @@ describe("project creation", () => {
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") }),
+ await screen.findByRole("option", { name: i18n.t(`projectCreate.templates.${id}.name`) }),
);
- expect(screen.getByText("services/api")).toBeDefined();
+ expect(screen.getByText(`${directory}/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(payload).toEqual({ name: "api", templateId: id });
expect(onClose).toHaveBeenCalledOnce();
});
@@ -174,7 +193,7 @@ describe("project creation", () => {
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 screen.findByRole("combobox", { name: i18n.t("projectCreate.template") });
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/locales/en-US.json b/apps/dashboard/src/locales/en-US.json
index 0d30ff9..e435f18 100644
--- a/apps/dashboard/src/locales/en-US.json
+++ b/apps/dashboard/src/locales/en-US.json
@@ -372,7 +372,7 @@
"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",
+ "template": "Technology stack or empty project",
"templatesFailed": "Could not load templates",
"loadingTemplates": "Loading templates…",
"noTemplates": "No templates available",
@@ -429,6 +429,18 @@
"electron-app": {
"name": "Electron desktop app",
"description": "Electron + React + Vite with inversify, electron-log, electron-store, and electron-updater"
+ },
+ "empty-app": {
+ "name": "Empty application",
+ "description": "Create an empty project under apps/ and configure a technology stack later."
+ },
+ "empty-service": {
+ "name": "Empty service",
+ "description": "Create an empty project under services/ and configure a technology stack later."
+ },
+ "empty-library": {
+ "name": "Empty library",
+ "description": "Create an empty project under packages/ and configure a technology stack later."
}
}
},
@@ -519,8 +531,30 @@
"feature1": "Separate main, renderer, and preload packages",
"feature2": "Typed IPC bridge and dependency injection",
"feature3": "Logging, local settings, and updater modules"
+ },
+ "empty-app": {
+ "description": "Create an empty project under apps/ and configure a technology stack later.",
+ "stack": "Choose your own technology stack",
+ "feature1": "Only a .gitkeep file for Git tracking",
+ "feature2": "Registered in the workspace",
+ "feature3": "Configure a development command after adding code"
+ },
+ "empty-service": {
+ "description": "Create an empty project under services/ and configure a technology stack later.",
+ "stack": "Choose your own technology stack",
+ "feature1": "Only a .gitkeep file for Git tracking",
+ "feature2": "Registered in the workspace",
+ "feature3": "Configure a development command after adding code"
+ },
+ "empty-library": {
+ "description": "Create an empty project under packages/ and configure a technology stack later.",
+ "stack": "Choose your own technology stack",
+ "feature1": "Only a .gitkeep file for Git tracking",
+ "feature2": "Registered in the workspace",
+ "feature3": "Configure a development command after adding code"
}
- }
+ },
+ "noToolchain": "Not configured"
},
"services": {
"title": "Development service",
diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json
index f493d4a..54114b3 100644
--- a/apps/dashboard/src/locales/zh-CN.json
+++ b/apps/dashboard/src/locales/zh-CN.json
@@ -372,7 +372,7 @@
"nameHint": "以字母或数字开头,可使用字母、数字、连字符和下划线。",
"nameInvalid": "请输入以字母或数字开头,且仅包含字母、数字、连字符或下划线的名称。",
"nameExists": "当前工作区已存在同名项目,请使用其他名称。",
- "template": "技术栈",
+ "template": "技术栈或空项目",
"templatesFailed": "无法加载模板",
"loadingTemplates": "正在加载模板…",
"noTemplates": "暂无可用模板",
@@ -429,6 +429,18 @@
"electron-app": {
"name": "Electron 桌面应用",
"description": "Electron + React + Vite 桌面应用模板,主进程基于 inversify,内置 electron-log/store/updater"
+ },
+ "empty-app": {
+ "name": "空应用",
+ "description": "在 apps/ 下创建空项目,稍后自行配置技术栈。"
+ },
+ "empty-service": {
+ "name": "空服务",
+ "description": "在 services/ 下创建空项目,稍后自行配置技术栈。"
+ },
+ "empty-library": {
+ "name": "空共享库",
+ "description": "在 packages/ 下创建空项目,稍后自行配置技术栈。"
}
}
},
@@ -519,8 +531,30 @@
"feature1": "主进程、渲染进程与 preload 分包",
"feature2": "带类型的 IPC 桥接与依赖注入",
"feature3": "日志、本地设置与更新器模块"
+ },
+ "empty-app": {
+ "description": "在 apps/ 下创建空项目,稍后自行配置技术栈。",
+ "stack": "技术栈由你选择",
+ "feature1": "仅包含用于 Git 跟踪的 .gitkeep 文件",
+ "feature2": "自动登记到工作区",
+ "feature3": "添加代码后自行配置开发命令"
+ },
+ "empty-service": {
+ "description": "在 services/ 下创建空项目,稍后自行配置技术栈。",
+ "stack": "技术栈由你选择",
+ "feature1": "仅包含用于 Git 跟踪的 .gitkeep 文件",
+ "feature2": "自动登记到工作区",
+ "feature3": "添加代码后自行配置开发命令"
+ },
+ "empty-library": {
+ "description": "在 packages/ 下创建空项目,稍后自行配置技术栈。",
+ "stack": "技术栈由你选择",
+ "feature1": "仅包含用于 Git 跟踪的 .gitkeep 文件",
+ "feature2": "自动登记到工作区",
+ "feature3": "添加代码后自行配置开发命令"
}
- }
+ },
+ "noToolchain": "未配置"
},
"services": {
"title": "开发服务",
diff --git a/apps/dashboard/src/pages/ProjectTemplates.test.tsx b/apps/dashboard/src/pages/ProjectTemplates.test.tsx
index 1d62042..2a46ae8 100644
--- a/apps/dashboard/src/pages/ProjectTemplates.test.tsx
+++ b/apps/dashboard/src/pages/ProjectTemplates.test.tsx
@@ -81,7 +81,9 @@ describe("project template catalog", () => {
show();
await screen.findAllByRole("article");
await user.click(screen.getByRole("button", { name: /Services/ }));
- expect(screen.getAllByRole("article")).toHaveLength(2);
+ expect(screen.getAllByRole("article")).toHaveLength(
+ templates.filter((template: { category: string }) => template.category === "backend").length,
+ );
await user.type(
screen.getByRole("textbox", { name: "Search templates, stacks, or features…" }),
" DRIZZLE ",
diff --git a/apps/dashboard/src/pages/ProjectTemplates.tsx b/apps/dashboard/src/pages/ProjectTemplates.tsx
index 4a7da04..f70f68b 100644
--- a/apps/dashboard/src/pages/ProjectTemplates.tsx
+++ b/apps/dashboard/src/pages/ProjectTemplates.tsx
@@ -42,6 +42,7 @@ export function ProjectTemplates() {
const catalog = useSWR(projectTemplatesKey, getProjectTemplates);
const [query, setQuery] = useState("");
const [category, setCategory] = useState<(typeof CATEGORIES)[number]>("all");
+ const toolchainLabels = { node: "Node.js", go: "Go", none: t("templateCatalog.noToolchain") };
const templates = (catalog.data?.templates ?? []).map((template) => ({
...template,
name: t(`projectCreate.templates.${template.id}.name`, { defaultValue: template.name }),
@@ -49,7 +50,7 @@ export function ProjectTemplates() {
defaultValue: template.description,
}),
stack: t(`templateCatalog.templates.${template.id}.stack`, {
- defaultValue: template.toolchain === "go" ? "Go" : "Node.js",
+ defaultValue: toolchainLabels[template.toolchain],
}),
features: ["feature1", "feature2", "feature3"]
.map((key) => t(`templateCatalog.templates.${template.id}.${key}`, { defaultValue: "" }))
@@ -65,7 +66,7 @@ export function ProjectTemplates() {
template.description,
template.stack,
template.directory,
- template.toolchain === "go" ? "Go" : "Node.js",
+ toolchainLabels[template.toolchain],
...template.features,
]
.join(" ")
@@ -224,7 +225,7 @@ export function ProjectTemplates() {
{template.directory}/
- {template.toolchain === "go" ? "Go" : "Node.js"}
+ {toolchainLabels[template.toolchain]}
);
diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md
index 90e74d1..301650d 100644
--- a/apps/docs/content/docs/en/add.md
+++ b/apps/docs/content/docs/en/add.md
@@ -3,7 +3,7 @@ title: one add
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.
+`one add` creates a project from a template or an empty directory starter and registers it in the workspace 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 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.
@@ -47,6 +47,20 @@ Non-interactive calls should pass both template ID and project name:
one add nestjs-api --name api --yes
```
+## Create an Empty Project
+
+In an existing workspace, create and register a project before choosing a language or framework:
+
+```bash
+one add empty-app --name web --yes
+one add empty-service --name api --yes
+one add empty-library --name shared --yes
+```
+
+These templates create `apps/web/`, `services/api/`, and `packages/shared/`, respectively, containing only a `.gitkeep` file so Git tracks the directory. They register `toolchain: "none"` and generate no `package.json`, `go.mod`, dependencies, or startup tasks. Interactive `one add` and the Dashboard's new-project picker also offer these choices.
+
+For Node or Go code, set the project's `toolchain` in `one.manifest.json` to `node` or `go`. Node projects use `packageManager: "pnpm"` and need membership in the root package workspace; Go modules need membership in the root `go.work`. Define tasks in `package.json` / `Taskfile.yml`, or set the project's `dev.command`, then run `one init mise` to update task configuration. For other languages, keep `toolchain: "none"` and define your own tools and tasks in the root `mise.toml`, or set `dev.command`. Configure the commands before using `one dev` / `one build`.
+
## Output
```json
diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md
index e73df0b..cf04711 100644
--- a/apps/docs/content/docs/zh/add.md
+++ b/apps/docs/content/docs/zh/add.md
@@ -5,7 +5,7 @@ description: 往工作区里加一个模板化项目。
工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。检查配置直接保存在 `.config/hk.pkl`,用户修改和注释会保留。工作区和项目任务统一登记在根 `mise.toml`,子项目继续使用自己的原生命令文件。旧工作区可先通过 `one init hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。
-`one add` 选择技术栈,生成一个可本地开发的项目并登记到 manifest。CI 和部署默认都保持未配置。
+`one add` 可以从技术栈模板生成项目,也可以创建空项目,并登记到工作区 manifest。CI 和部署默认都保持未配置。
有两条入口:
@@ -43,6 +43,20 @@ one add [template-id] --name [options]
one add nestjs-api --name api --yes
```
+## 创建空项目
+
+在已有工作区中,可以先创建目录并登记项目,之后再选择语言或框架:
+
+```bash
+one add empty-app --name web --yes
+one add empty-service --name api --yes
+one add empty-library --name shared --yes
+```
+
+三个模板分别创建 `apps/web/`、`services/api/`、`packages/shared/`,仅包含用于 Git 跟踪目录的 `.gitkeep`。它们以 `toolchain: "none"` 登记,不生成 `package.json`、`go.mod`、依赖或启动任务。交互式 `one add` 和 Dashboard 的新建项目选择器也提供这三个选项。
+
+如果使用 Node 或 Go,在 `one.manifest.json` 中将项目的 `toolchain` 改为 `node` 或 `go`;Node 项目使用 `packageManager: "pnpm"`,并补齐根工作区的包成员配置,Go 项目则将模块加入根 `go.work`。在 `package.json` / `Taskfile.yml` 中定义任务,或设置项目的 `dev.command`,然后运行 `one init mise` 更新任务配置。使用其他语言时,可以保留 `toolchain: "none"`,在根 `mise.toml` 中自行定义工具和任务,或设置 `dev.command`。配置好命令后再使用 `one dev` / `one build`。
+
## 输出
```json
diff --git a/packages/cli/internal/core/template/registry.go b/packages/cli/internal/core/template/registry.go
index 5ec8413..a51a842 100644
--- a/packages/cli/internal/core/template/registry.go
+++ b/packages/cli/internal/core/template/registry.go
@@ -24,6 +24,8 @@ type Toolchain string
const (
ToolchainNode Toolchain = "node"
ToolchainGo Toolchain = "go"
+ // Empty projects keep an explicit value because legacy manifests default an empty toolchain to Node.
+ ToolchainNone Toolchain = "none"
)
// Category groups templates in --help and `templates` output.
@@ -267,6 +269,7 @@ var validCategories = map[Category]struct{}{
var validToolchains = map[Toolchain]struct{}{
ToolchainNode: {},
ToolchainGo: {},
+ ToolchainNone: {},
}
func validateTemplate(raw map[string]json.RawMessage, index int) (Template, error) {
diff --git a/packages/cli/internal/modules/creation/project.go b/packages/cli/internal/modules/creation/project.go
index c9431d5..765a32d 100644
--- a/packages/cli/internal/modules/creation/project.go
+++ b/packages/cli/internal/modules/creation/project.go
@@ -288,14 +288,14 @@ func categoryDirFor(category string) (string, error) {
}
func defaultPackageManagerFor(tc string) string {
- if tc == "go" {
+ if tc == "go" || tc == string(template.ToolchainNone) {
return ""
}
return "pnpm"
}
func manifestPackageManagerFor(tc, pm string) string {
- if tc == "go" {
+ if tc == "go" || tc == string(template.ToolchainNone) {
return ""
}
return pm
diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json
index 6694257..fdbe66e 100644
--- a/packages/cli/internal/platform/i18n/locales/en-US.json
+++ b/packages/cli/internal/platform/i18n/locales/en-US.json
@@ -56,13 +56,13 @@
"error.BACKEND_VERB_NOT_SUPPORTED.message": "The current environment-variable source does not support this operation.",
"error.RUN_COMMAND_NOT_FOUND.message": "The command to run was not found.",
"add.short": "Add a project from a technology stack",
- "add.tip": "Adds a project ready for local development and builds.",
+ "add.tip": "Add a project from a template, or start empty and configure a technology stack later.",
"add.success": "✓ Project added: %s",
"add.location": " Location: %s",
"add.stack": " Technology stack: %s (%s)",
"add.package_manager": " Package manager: %s",
"add.prompt_kind": "What would you like to add?",
- "add.prompt_stack": "Choose a technology stack",
+ "add.prompt_stack": "Choose a technology stack or an empty project",
"add.prompt_name": "Project name",
"add.kind.application": "Application",
"add.kind.service": "Service",
@@ -642,5 +642,12 @@
"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."
+ "devservice.forget_active": "Stop this workspace’s development services before removing it from the list.",
+ "add.empty_next_steps": " Add your code, then configure toolchain and the development command in one.manifest.json before running the project.",
+ "template.empty-app.name": "Empty application",
+ "template.empty-app.description": "Create an empty project under apps/ and configure a technology stack later",
+ "template.empty-service.name": "Empty service",
+ "template.empty-service.description": "Create an empty project under services/ and configure a technology stack later",
+ "template.empty-library.name": "Empty library",
+ "template.empty-library.description": "Create an empty project under packages/ and configure a technology stack later"
}
diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json
index 798f222..8375233 100644
--- a/packages/cli/internal/platform/i18n/locales/zh-CN.json
+++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json
@@ -56,13 +56,13 @@
"error.BACKEND_VERB_NOT_SUPPORTED.message": "当前环境变量来源不支持这个操作。",
"error.RUN_COMMAND_NOT_FOUND.message": "找不到要运行的命令。",
"add.short": "从技术栈添加项目",
- "add.tip": "添加一个可直接本地开发和构建的项目。",
+ "add.tip": "从模板添加项目,也可选择空项目后自行配置技术栈。",
"add.success": "✓ 项目已添加:%s",
"add.location": " 位置:%s",
"add.stack": " 技术栈:%s(%s)",
"add.package_manager": " 包管理器:%s",
"add.prompt_kind": "想添加什么?",
- "add.prompt_stack": "选择技术栈",
+ "add.prompt_stack": "选择技术栈或空项目",
"add.prompt_name": "项目名称",
"add.kind.application": "应用",
"add.kind.service": "服务",
@@ -642,5 +642,12 @@
"devservice.url_invalid": "访问地址需要是本机 HTTP(S) 地址,且不含凭据、查询参数或片段。",
"devservice.interactive": "此 dev 任务需要交互式终端,请在外部控制台运行 one dev。",
"devservice.unavailable": "服务管理器不可用,请重新启动 one serve。",
- "devservice.forget_active": "请先停止此工作区的开发服务,再从列表移除工作区。"
+ "devservice.forget_active": "请先停止此工作区的开发服务,再从列表移除工作区。",
+ "add.empty_next_steps": " 添加代码,并在 one.manifest.json 中配置 toolchain 和开发命令后再运行项目。",
+ "template.empty-app.name": "空应用",
+ "template.empty-app.description": "在 apps/ 下创建空项目,稍后自行配置技术栈",
+ "template.empty-service.name": "空服务",
+ "template.empty-service.description": "在 services/ 下创建空项目,稍后自行配置技术栈",
+ "template.empty-library.name": "空共享库",
+ "template.empty-library.description": "在 packages/ 下创建空项目,稍后自行配置技术栈"
}
diff --git a/packages/cli/internal/transport/cobra/add/cmd.go b/packages/cli/internal/transport/cobra/add/cmd.go
index 2750af2..b8ac731 100644
--- a/packages/cli/internal/transport/cobra/add/cmd.go
+++ b/packages/cli/internal/transport/cobra/add/cmd.go
@@ -43,7 +43,7 @@ func newAddCmd(service *creationmodule.Service) *cobra.Command {
cmd := &cobra.Command{
Use: "add [template-id]",
Long: i18n.T("add.tip"),
- Example: " one add\n one add react-spa --name web --yes",
+ Example: " one add\n one add react-spa --name web --yes\n one add empty-app --name web --yes\n one add empty-service --name api --yes",
Args: i18n.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
positional := ""
@@ -194,6 +194,10 @@ func (r *addResult) RenderTTY(w io.Writer) {
}
fmt.Fprintln(w)
fmt.Fprintln(w, i18n.T("common.next_steps"))
+ if r.Toolchain == string(template.ToolchainNone) {
+ fmt.Fprintln(w, i18n.T("add.empty_next_steps"))
+ return
+ }
fmt.Fprintf(w, " one dev -p %s\n", r.SubprojectName)
}
diff --git a/packages/cli/internal/transport/http/handlers_project_create_test.go b/packages/cli/internal/transport/http/handlers_project_create_test.go
index 7d3ed0b..6aac6af 100644
--- a/packages/cli/internal/transport/http/handlers_project_create_test.go
+++ b/packages/cli/internal/transport/http/handlers_project_create_test.go
@@ -161,3 +161,47 @@ func TestProjectTemplatesLocalized(t *testing.T) {
}
}
}
+
+func TestCreateEmptyProjectsInSelectedWorkspace(t *testing.T) {
+ registry := newRegistryService(t)
+ root := seedRegistryWorkspace(t, "selected", "Selected", "")
+ selected := observeRegistryWorkspace(t, registry, root)
+ handler := newRegistryMux(t, root, registry)
+ for _, tc := range []struct{ id, name, dir string }{
+ {"empty-app", "web", "apps/web"},
+ {"empty-service", "api", "services/api"},
+ {"empty-library", "shared", "packages/shared"},
+ } {
+ payload, err := json.Marshal(createProjectRequest{Name: tc.name, TemplateID: tc.id})
+ if err != nil {
+ t.Fatal(err)
+ }
+ response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(string(payload)))
+ if response.Code != http.StatusCreated {
+ t.Fatalf("%s: %d %s", tc.id, response.Code, response.Body.String())
+ }
+ var created createProjectResponse
+ if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil {
+ t.Fatal(err)
+ }
+ if created.Name != tc.name || created.RelativeDir != tc.dir || created.TemplateID != tc.id {
+ t.Fatalf("created = %#v", created)
+ }
+ entries, err := os.ReadDir(filepath.Join(root, tc.dir))
+ if err != nil || len(entries) != 1 || entries[0].Name() != ".gitkeep" {
+ t.Fatalf("empty project files: %v, %v", entries, err)
+ }
+ }
+ manifest, err := workspacecore.ReadManifest(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(manifest.Projects) != 3 {
+ t.Fatalf("manifest = %#v", manifest)
+ }
+ for _, project := range manifest.Projects {
+ if project.Toolchain != "none" || project.PackageManager != "" || project.Dev != nil {
+ t.Fatalf("empty project = %#v", project)
+ }
+ }
+}
diff --git a/packages/cli/testdata/preset/v1_codes.json b/packages/cli/testdata/preset/v1_codes.json
index ca992d1..3acf883 100644
--- a/packages/cli/testdata/preset/v1_codes.json
+++ b/packages/cli/testdata/preset/v1_codes.json
@@ -41,6 +41,18 @@
{
"code": "tl",
"template_id": "ts-library"
+ },
+ {
+ "code": "xa",
+ "template_id": "empty-app"
+ },
+ {
+ "code": "xl",
+ "template_id": "empty-library"
+ },
+ {
+ "code": "xs",
+ "template_id": "empty-service"
}
],
"envs": [
diff --git a/packages/cli/testdata/reference/help/add.txt b/packages/cli/testdata/reference/help/add.txt
index 1f6c49a..27e3ba3 100644
--- a/packages/cli/testdata/reference/help/add.txt
+++ b/packages/cli/testdata/reference/help/add.txt
@@ -8,9 +8,11 @@ USAGE
EXAMPLES
one add
one add react-spa --name web --yes
+ one add empty-app --name web --yes
+ one add empty-service --name api --yes
TIPS
-Adds a project ready for local development and builds.
+Add a project from a template, or start empty and configure a technology stack later.
COMMON OPTIONS
-n, --name Project name
diff --git a/packages/cli/testdata/reference/templates.json b/packages/cli/testdata/reference/templates.json
index 38340f5..d0c1dd3 100644
--- a/packages/cli/testdata/reference/templates.json
+++ b/packages/cli/testdata/reference/templates.json
@@ -159,7 +159,46 @@
"frontend"
],
"toolchain": "node"
+ },
+ {
+ "category": "frontend",
+ "code": "xa",
+ "description": "在 apps/ 下创建空项目,稍后自行配置技术栈",
+ "id": "empty-app",
+ "name": "空应用",
+ "repo": "local:empty-app",
+ "tags": [
+ "empty",
+ "frontend"
+ ],
+ "toolchain": "none"
+ },
+ {
+ "category": "backend",
+ "code": "xs",
+ "description": "在 services/ 下创建空项目,稍后自行配置技术栈",
+ "id": "empty-service",
+ "name": "空服务",
+ "repo": "local:empty-service",
+ "tags": [
+ "empty",
+ "backend"
+ ],
+ "toolchain": "none"
+ },
+ {
+ "category": "library",
+ "code": "xl",
+ "description": "在 packages/ 下创建空项目,稍后自行配置技术栈",
+ "id": "empty-library",
+ "name": "空共享库",
+ "repo": "local:empty-library",
+ "tags": [
+ "empty",
+ "library"
+ ],
+ "toolchain": "none"
}
],
- "total": 10
+ "total": 13
}
diff --git a/packages/cli/tests/e2e/empty_project_test.go b/packages/cli/tests/e2e/empty_project_test.go
new file mode 100644
index 0000000..747bf26
--- /dev/null
+++ b/packages/cli/tests/e2e/empty_project_test.go
@@ -0,0 +1,84 @@
+package cli_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestE2E_AddEmptyProjects(t *testing.T) {
+ for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} {
+ t.Run(locale, func(t *testing.T) {
+ tmp := t.TempDir()
+ isolateHome(t, tmp)
+ t.Setenv("LC_ALL", locale)
+ root := bootstrapWorkspace(t, tmp, "empty-projects")
+ initialMise, err := os.ReadFile(filepath.Join(root, "mise.toml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct{ id, name, directory string }{
+ {"empty-app", "web", "apps"},
+ {"empty-service", "api", "services"},
+ {"empty-library", "shared", "packages"},
+ } {
+ stdout, stderr, code := runBinaryIn(t, root, "add", tc.id, "--name", tc.name, "--yes", "-o", "json")
+ if code != 0 {
+ t.Fatalf("add %s: exit %d\n%s\n%s", tc.id, code, stdout, stderr)
+ }
+ result := mustParseJSON(t, stdout)
+ if result["template_id"] != tc.id || result["toolchain"] != "none" || result["package_manager"] != nil {
+ t.Fatalf("unexpected result: %v", result)
+ }
+ dir := filepath.Join(root, tc.directory, tc.name)
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 1 || entries[0].Name() != ".gitkeep" {
+ t.Fatalf("empty project files = %v, err = %v", entries, err)
+ }
+ // Reusing the name in another category must neither register it nor
+ // leave the newly rendered directory behind.
+ _, _, code = runBinaryIn(t, root, "add", "empty-library", "--name", tc.name, "--yes", "-o", "json")
+ if code == 0 {
+ t.Fatal("duplicate project name was accepted")
+ }
+ if tc.directory != "packages" {
+ if _, err := os.Stat(filepath.Join(root, "packages", tc.name)); !os.IsNotExist(err) {
+ t.Fatalf("duplicate left a directory: %v", err)
+ }
+ }
+ }
+ manifest := readManifest(t, root)
+ projects := manifest["projects"].([]any)
+ if len(projects) != 3 {
+ t.Fatalf("projects = %v", projects)
+ }
+ for _, raw := range projects {
+ p := raw.(map[string]any)
+ if p["toolchain"] != "none" || p["dev"] != nil || p["packageManager"] != nil {
+ t.Fatalf("empty project acquired runtime configuration: %v", p)
+ }
+ }
+ for _, file := range []string{"package.json", "pnpm-workspace.yaml", "go.work"} {
+ if _, err := os.Stat(filepath.Join(root, file)); !os.IsNotExist(err) {
+ t.Fatalf("empty projects generated %s: %v", file, err)
+ }
+ }
+ mise, err := os.ReadFile(filepath.Join(root, "mise.toml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(mise) != string(initialMise) {
+ t.Fatalf("empty projects changed workspace tools or tasks:\n%s", mise)
+ }
+ stdout, stderr, code := runBinaryIn(t, root, "add", "empty-app", "--name", "custom", "--yes", "-o", "text")
+ hint := "Add your code"
+ if locale == "zh_CN.UTF-8" {
+ hint = "添加代码"
+ }
+ if code != 0 || !strings.Contains(stdout, hint) || strings.Contains(stdout, "one dev -p") {
+ t.Fatalf("empty project guidance: exit %d\n%s\n%s", code, stdout, stderr)
+ }
+ })
+ }
+}
diff --git a/packages/templates/empty-app/.gitkeep b/packages/templates/empty-app/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packages/templates/empty-library/.gitkeep b/packages/templates/empty-library/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packages/templates/empty-service/.gitkeep b/packages/templates/empty-service/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packages/templates/registry.json b/packages/templates/registry.json
index edad9fe..d13ad53 100644
--- a/packages/templates/registry.json
+++ b/packages/templates/registry.json
@@ -169,6 +169,48 @@
"toolchain": "node",
"repo": "local:electron-app",
"domains": {}
+ },
+ {
+ "id": "empty-app",
+ "code": "xa",
+ "name": "空应用",
+ "description": "在 apps/ 下创建空项目,稍后自行配置技术栈",
+ "category": "frontend",
+ "tags": [
+ "empty",
+ "frontend"
+ ],
+ "toolchain": "none",
+ "repo": "local:empty-app",
+ "domains": {}
+ },
+ {
+ "id": "empty-service",
+ "code": "xs",
+ "name": "空服务",
+ "description": "在 services/ 下创建空项目,稍后自行配置技术栈",
+ "category": "backend",
+ "tags": [
+ "empty",
+ "backend"
+ ],
+ "toolchain": "none",
+ "repo": "local:empty-service",
+ "domains": {}
+ },
+ {
+ "id": "empty-library",
+ "code": "xl",
+ "name": "空共享库",
+ "description": "在 packages/ 下创建空项目,稍后自行配置技术栈",
+ "category": "library",
+ "tags": [
+ "empty",
+ "library"
+ ],
+ "toolchain": "none",
+ "repo": "local:empty-library",
+ "domains": {}
}
]
}
From edfc7d315f43118c62faf4d83601e2aca5a9b623 Mon Sep 17 00:00:00 2001
From: caorushizi <84996057@qq.com>
Date: Tue, 29 Sep 2026 01:58:11 +0800
Subject: [PATCH 13/16] feat(cli): auto-update release builds while preserving
development installs
---
.goreleaser.yaml | 1 +
apps/docs/content/docs/en/installation.md | 10 +
apps/docs/content/docs/zh/installation.md | 10 +
packages/cli/cmd/one/main.go | 4 +
packages/cli/internal/bootstrap/cli/root.go | 8 +-
.../internal/platform/i18n/locales/en-US.json | 15 +-
.../internal/platform/i18n/locales/zh-CN.json | 15 +-
.../platform/updatecheck/background.go | 253 +++++++++----
.../platform/updatecheck/background_test.go | 147 ++++++++
.../internal/platform/updatecheck/cache.go | 12 +-
.../platform/updatecheck/cache_test.go | 1 +
.../internal/platform/updatecheck/checker.go | 15 +-
.../platform/updatecheck/checker_test.go | 34 +-
.../internal/platform/updatecheck/install.go | 356 ++++++++++++++++++
.../platform/updatecheck/install_test.go | 219 +++++++++++
.../internal/platform/updatecheck/notify.go | 51 ++-
.../platform/updatecheck/notify_test.go | 94 ++++-
.../internal/platform/updatecheck/semver.go | 32 +-
.../platform/updatecheck/semver_test.go | 6 +-
.../updatecheck/worker_integration_test.go | 126 +++++++
.../platform/updatecheck/worker_unix.go | 15 +
.../platform/updatecheck/worker_windows.go | 32 ++
22 files changed, 1306 insertions(+), 150 deletions(-)
create mode 100644 packages/cli/internal/platform/updatecheck/background_test.go
create mode 100644 packages/cli/internal/platform/updatecheck/install.go
create mode 100644 packages/cli/internal/platform/updatecheck/install_test.go
create mode 100644 packages/cli/internal/platform/updatecheck/worker_integration_test.go
create mode 100644 packages/cli/internal/platform/updatecheck/worker_unix.go
create mode 100644 packages/cli/internal/platform/updatecheck/worker_windows.go
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 2ba6f10..766eb98 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -36,6 +36,7 @@ builds:
ldflags:
- -s -w
- -X main.version={{.Version}}
+ - -X github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck.buildChannel=release
flags:
- -trimpath
diff --git a/apps/docs/content/docs/en/installation.md b/apps/docs/content/docs/en/installation.md
index af45e2e..f5f6401 100644
--- a/apps/docs/content/docs/en/installation.md
+++ b/apps/docs/content/docs/en/installation.md
@@ -72,6 +72,16 @@ one --version
On Windows, the archive is `one-cli_windows_amd64.zip`.
+## Automatic Updates
+
+Stable release builds start an independent background updater at most once every 24 hours during normal terminal use. It downloads the platform archive and `checksums.txt` from the official GitHub Release, verifies SHA256 and the executable version, then replaces the installed program. The current command keeps running; subsequent invocations use the new version. On Windows, replacement waits for the command that started the update to exit.
+
+Download, verification, or write failures preserve the installed program. When a newer release is known, One shows the failure and a manual update command. If the executable changes during the download, such as after a local rebuild, the updater refuses to overwrite it. Failed attempts also retry on the 24-hour interval.
+
+**Development builds skip update checks, downloads, installation, and notifications entirely.** Builds from `go build`, `mise run build`, `mise run build-local`, and `mise run install` default to updates disabled, even when `RELEASE_VERSION` is set. Only the release packager enables the update marker, and a complete stable version is also required. Development, local, snapshot, and other prerelease versions never update automatically.
+
+CI, structured JSON/YAML output, `--dry-run`, and internal task processes do not start updates. Update state lives at `$XDG_CACHE_HOME/one/update-check.json`, defaulting to `~/.cache/one/update-check.json`.
+
## Upgrade And Downgrade
`install.sh` and `install.ps1` check the installed `one --version` before deciding what to do:
diff --git a/apps/docs/content/docs/zh/installation.md b/apps/docs/content/docs/zh/installation.md
index 794114e..903f3b6 100644
--- a/apps/docs/content/docs/zh/installation.md
+++ b/apps/docs/content/docs/zh/installation.md
@@ -72,6 +72,16 @@ one --version
Windows 归档名是 `one-cli_windows_amd64.zip`。
+## 自动更新
+
+正式稳定版在普通终端使用时,每 24 小时最多启动一次独立后台更新进程。它从官方 GitHub Release 下载对应平台归档和 `checksums.txt`,校验 SHA256 与程序版本后替换当前安装。当前命令继续运行,新版本从下一次调用生效;Windows 会等发起更新的命令退出后再替换程序。
+
+下载、校验或写入失败时保留当前程序;已发现新版本时会提示失败原因和手动更新命令。如果下载期间程序已被重新构建或替换,更新器会放弃覆盖。更新失败也按 24 小时间隔重试。
+
+**开发构建完全跳过更新检查、下载、安装和提示。** `go build`、`mise run build`、`mise run build-local`、`mise run install` 生成的包默认关闭更新,即使设置了 `RELEASE_VERSION`。只有发布打包流程启用更新标记,并且版本必须是完整的稳定版本号;`-dev`、`-local`、snapshot 和其他预发布版本都不会自动更新。
+
+CI、JSON/YAML 等结构化输出、`--dry-run` 以及内部任务进程不启动更新。更新状态位于 `$XDG_CACHE_HOME/one/update-check.json`,默认是 `~/.cache/one/update-check.json`。
+
## 升级与降级
`install.sh` 和 `install.ps1` 都会先读已装 `one --version` 再决定怎么处理:
diff --git a/packages/cli/cmd/one/main.go b/packages/cli/cmd/one/main.go
index 909a2f4..1efc13e 100644
--- a/packages/cli/cmd/one/main.go
+++ b/packages/cli/cmd/one/main.go
@@ -9,6 +9,7 @@ import (
"github.com/torchstellar-team/one-cli/packages/cli/internal/bootstrap/cli"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output"
platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck"
)
// version is overridden at build time via -ldflags. Keep the source fallback
@@ -16,6 +17,9 @@ import (
var version = "0.0.0-dev"
func main() {
+ if updatecheck.RunWorker(version, os.Args[1:]) {
+ return
+ }
if err := cli.Execute(version, os.Args[1:]); err != nil {
var exit *platformprocess.ExitStatus
if errors.As(err, &exit) {
diff --git a/packages/cli/internal/bootstrap/cli/root.go b/packages/cli/internal/bootstrap/cli/root.go
index 45846c8..7529b26 100644
--- a/packages/cli/internal/bootstrap/cli/root.go
+++ b/packages/cli/internal/bootstrap/cli/root.go
@@ -134,11 +134,9 @@ func Execute(version string, args []string) (resultErr error) {
_ = i18n.Init(i18n.Resolve(stored))
i18n.RefreshTree(rootCmd)
- // Background update check kicks off here so its goroutine has the
- // whole command runtime to finish; the notification (if any) prints
- // in the defer below from cached state. Both calls are no-ops on
- // CI / -o json / dev builds / opt-out, so this is free in those
- // paths. See internal/platform/updatecheck.
+ // Official releases may start an independent update worker. The command
+ // never waits for a download; cached completion/failure notices are printed
+ // on exit. Development builds, CI, and structured output skip this path.
if shouldCheckUpdates(args) {
updatecheck.MaybeRefreshAsync(version)
defer updatecheck.Notify(version)
diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json
index fdbe66e..59dbafc 100644
--- a/packages/cli/internal/platform/i18n/locales/en-US.json
+++ b/packages/cli/internal/platform/i18n/locales/en-US.json
@@ -649,5 +649,18 @@
"template.empty-service.name": "Empty service",
"template.empty-service.description": "Create an empty project under services/ and configure a technology stack later",
"template.empty-library.name": "Empty library",
- "template.empty-library.description": "Create an empty project under packages/ and configure a technology stack later"
+ "template.empty-library.description": "Create an empty project under packages/ and configure a technology stack later",
+ "update.installed": "One CLI was automatically updated to %s; new invocations use the updated version.",
+ "update.failed": "One CLI could not finish updating. The installed program was preserved; update manually with the command below.\nReason: %s",
+ "update.redirect_invalid": "Update downloads require HTTPS redirects and at most 10 redirects.",
+ "update.release_required": "Only stable release builds can update automatically.",
+ "update.platform_unsupported": "No One CLI update archive is available for %s/%s.",
+ "update.checksum_mismatch": "SHA256 verification failed for update archive %s.",
+ "update.checksum_invalid": "The SHA256 entry for update archive %s is missing, invalid, or duplicated.",
+ "update.http_failed": "Update download failed with HTTP status %d.",
+ "update.download_too_large": "The update download exceeds the size limit.",
+ "update.archive_invalid": "The update archive must contain exactly one valid One CLI executable.",
+ "update.target_changed": "The executable at %s changed or is not a regular file; automatic replacement was canceled.",
+ "update.version_mismatch": "The update executable reported version %s instead of the expected %s.",
+ "update.version_invalid": "The update service returned an invalid stable version: %s"
}
diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json
index 8375233..f5a31d2 100644
--- a/packages/cli/internal/platform/i18n/locales/zh-CN.json
+++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json
@@ -649,5 +649,18 @@
"template.empty-service.name": "空服务",
"template.empty-service.description": "在 services/ 下创建空项目,稍后自行配置技术栈",
"template.empty-library.name": "空共享库",
- "template.empty-library.description": "在 packages/ 下创建空项目,稍后自行配置技术栈"
+ "template.empty-library.description": "在 packages/ 下创建空项目,稍后自行配置技术栈",
+ "update.installed": "One CLI 已自动更新到 %s;新版本将在下一次运行时生效。",
+ "update.failed": "One CLI 自动更新未完成,当前程序已保留。可使用下面的命令手动更新。\n原因:%s",
+ "update.redirect_invalid": "更新下载的重定向必须使用 HTTPS,且不能超过 10 次。",
+ "update.release_required": "只有正式发布版可以自动更新。",
+ "update.platform_unsupported": "没有适用于 %s/%s 的 One CLI 更新包。",
+ "update.checksum_mismatch": "更新包 %s 的 SHA256 校验失败。",
+ "update.checksum_invalid": "更新包 %s 的 SHA256 记录缺失、无效或重复。",
+ "update.http_failed": "更新下载失败,HTTP 状态码为 %d。",
+ "update.download_too_large": "更新下载超过允许的大小。",
+ "update.archive_invalid": "更新包未包含唯一且有效的 One CLI 程序。",
+ "update.target_changed": "程序 %s 已变化或不是普通文件,已取消自动替换。",
+ "update.version_mismatch": "更新程序报告版本 %s,与预期版本 %s 不符。",
+ "update.version_invalid": "更新服务返回了无效的正式版本:%s"
}
diff --git a/packages/cli/internal/platform/updatecheck/background.go b/packages/cli/internal/platform/updatecheck/background.go
index ae24de7..9ac50e0 100644
--- a/packages/cli/internal/platform/updatecheck/background.go
+++ b/packages/cli/internal/platform/updatecheck/background.go
@@ -1,108 +1,201 @@
package updatecheck
-// Background-fetch dispatcher. Decides whether the cache is stale enough
-// to warrant a network request, then runs that request in a detached
-// goroutine that does NOT block the main command.
-//
-// Trade-off: if the main command exits before the goroutine completes,
-// the cache write is lost and we re-fetch next time. That's fine — most
-// commands run >100ms, the GitHub Releases redirect usually
-// responds in <300ms over good network, and long-running commands
-// (`one serve`, `one dev`) easily give the goroutine
-// time to finish.
-
import (
"context"
- "sync"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
"time"
-)
-// refreshInterval is how stale the cached result must be before we go
-// back to the network. 24h matches what `npm` / `brew` / similar
-// peripheral checkers do. Anything tighter would burn the operator's
-// origin bandwidth; anything looser would let critical-fix releases sit
-// unnoticed for too long.
-const refreshInterval = 24 * time.Hour
+ "github.com/gofrs/flock"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/preferences"
+)
-// inflight prevents two concurrent fetches in the same process — not a
-// realistic problem today (Execute is called once per process invocation),
-// but cheap insurance if cli.Execute ever gets called more than once.
-var inflight sync.Mutex
+// Only the release packager overrides this. Setting main.version for a local
+// go/task/mise build is deliberately insufficient to enable self-updates.
+var buildChannel = "development"
-// refreshDone is closed when the background goroutine started by
-// MaybeRefreshAsync finishes (success or failure), or immediately when
-// no fetch was needed. Notify selects on it with a short deadline so
-// fast commands (--help / --version, ~10ms) still get a chance to
-// populate the cache on first run instead of forever skipping past
-// the goroutine's window.
-var refreshDone chan struct{}
+const refreshInterval = 24 * time.Hour
+const workerCommand = "__self-update"
+const workerPrefix = ".update-worker-"
-// MaybeRefreshAsync kicks off a background version check if all skip
-// rules pass and the cache is stale (or absent). Returns immediately —
-// the goroutine writes to the cache file, no main-thread synchronisation.
-//
-// Pass the current binary's version (from main.version / cobra
-// rootCmd.Version) so the User-Agent is honest and shouldSkip's dev-build
-// short-circuit fires correctly.
+// MaybeRefreshAsync starts an independent worker at most once a day. It never
+// waits for network I/O, and the worker survives short commands such as --help.
func MaybeRefreshAsync(currentVersion string) {
if shouldSkip(currentVersion) {
return
}
- if !inflight.TryLock() {
- return // another goroutine already running for this process
- }
- c, err := loadCache()
- if err == nil && c != nil && time.Since(c.LastChecked) < refreshInterval {
- inflight.Unlock()
+ target, err := os.Executable()
+ if err != nil {
return
}
- refreshDone = make(chan struct{})
- go func() {
- defer close(refreshDone)
- defer inflight.Unlock()
- runRefresh(currentVersion)
- }()
+ startUpdate(currentVersion, target, launchWorker)
}
-// notifyWait is how long Notify will block waiting for an in-flight
-// refresh goroutine to finish. Short enough not to be perceptible
-// (CLI usability research puts the human-noticeable threshold at ~200ms),
-// long enough that a typical latest-release round-trip on a residential
-// connection (~80ms warm DNS, ~50ms TLS, ~50ms response) usually fits.
-const notifyWait = 200 * time.Millisecond
+func freshFor(c *Cache, version, target string) bool {
+ return c != nil && c.TargetPath == target &&
+ (c.CurrentVersion == version || c.InstalledVersion != "" && c.InstalledVersion == normalizeTag(version)) &&
+ time.Since(c.LastChecked) < refreshInterval
+}
-// waitForRefresh blocks the caller for at most notifyWait if a fetch is
-// in flight. No-op if no fetch was started (fresh cache or skip rule).
-// Called by Notify to bridge the case where the host command exits
-// faster than the network round-trip — without this, --help / --version
-// runs would never populate the cache.
-func waitForRefresh() {
- d := refreshDone
- if d == nil {
+func startUpdate(version, target string, launch func(string, string) error) {
+ path, err := cachePath()
+ if err != nil {
return
}
- select {
- case <-d:
- case <-time.After(notifyWait):
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return
+ }
+ lock := flock.New(path + ".lock")
+ locked, err := lock.TryLock()
+ if err != nil || !locked {
+ return
+ }
+ defer lock.Unlock()
+ previous, _ := loadCache()
+ if freshFor(previous, version, target) {
+ return
+ }
+ c := &Cache{LastChecked: time.Now().UTC(), CurrentVersion: version, TargetPath: target, Status: "checking"}
+ if previous != nil {
+ c.LatestVersion = previous.LatestVersion
+ }
+ // Publish the attempt before starting the worker. A failed launch is also
+ // rate-limited, so a read-only install never delays every command.
+ if err := saveCache(c); err != nil {
+ return
+ }
+ if err := launch(target, filepath.Dir(path)); err != nil {
+ c.Status, c.Error = "failed", err.Error()
+ _ = saveCache(c)
+ }
+}
+
+func launchWorker(target, cacheDir string) error {
+ cleanupWorkers(cacheDir)
+ dir, err := os.MkdirTemp(cacheDir, workerPrefix)
+ if err != nil {
+ return err
+ }
+ started := false
+ defer func() {
+ if !started {
+ _ = os.RemoveAll(dir)
+ }
+ }()
+ worker := filepath.Join(dir, executableName())
+ // A separate executable lets Windows install after the invoking process
+ // exits without the updater itself keeping the target binary locked.
+ if err := copyExecutable(target, worker); err != nil {
+ return err
+ }
+ cmd := exec.Command(worker, workerCommand, target, strconv.Itoa(os.Getpid()))
+ detachWorker(cmd)
+ // Nil streams map to the null device, never to a user's terminal or pipe.
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ started = true
+ go func() { _ = cmd.Wait() }()
+ return nil
+}
+
+func cleanupWorkers(dir string) {
+ entries, _ := os.ReadDir(dir)
+ for _, entry := range entries {
+ if !entry.IsDir() || !strings.HasPrefix(entry.Name(), workerPrefix) {
+ continue
+ }
+ info, err := entry.Info()
+ if err == nil && time.Since(info.ModTime()) > refreshInterval {
+ // On Windows, a running worker's executable cannot be deleted;
+ // stale workers from previous runs can be reclaimed here.
+ _ = os.RemoveAll(filepath.Join(dir, entry.Name()))
+ }
}
}
-// runRefresh does the actual network call + cache write. Always updates
-// LastChecked even on fetch failure — that rate-limits retry attempts so
-// a transient network blip doesn't cause every subsequent command to
-// re-fire the same failing request.
-func runRefresh(currentVersion string) {
- ctx := context.Background()
- latest, err := fetchLatest(ctx, currentVersion)
- c := &Cache{LastChecked: time.Now().UTC()}
+// RunWorker consumes the private worker invocation before Cobra, prompts, or
+// application services are initialized. Non-release builds always do nothing.
+func RunWorker(version string, args []string) bool {
+ return runWorker(version, args, defaultUpdater)
+}
+
+func runWorker(version string, args []string, makeUpdater func() updater) bool {
+ if len(args) == 0 || args[0] != workerCommand {
+ return false
+ }
+ if len(args) != 3 || buildChannel != "release" || !isStableRelease(version) {
+ return true
+ }
+ worker, err := os.Executable()
+ if err != nil {
+ return true
+ }
+ path, err := cachePath()
+ if err != nil {
+ return true
+ }
+ workerDir := filepath.Dir(worker)
+ cacheDir, err := filepath.EvalSymlinks(filepath.Dir(path))
+ if err != nil {
+ return true
+ }
+ if filepath.Dir(workerDir) != cacheDir || !strings.HasPrefix(filepath.Base(workerDir), workerPrefix) {
+ return true
+ }
+ defer os.RemoveAll(workerDir)
+ target := args[1]
+ parent, err := strconv.Atoi(args[2])
+ if err != nil || parent <= 0 || !filepath.IsAbs(target) {
+ return true
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+ lock := flock.New(path + ".lock")
+ locked, err := lock.TryLockContext(ctx, 50*time.Millisecond)
+ if err != nil || !locked {
+ return true
+ }
+ defer lock.Unlock()
+ c, err := loadCache()
+ if err != nil || c == nil || c.TargetPath != target || c.CurrentVersion != version || c.Status != "checking" {
+ return true
+ }
+ // Only replace the exact executable that was copied by the parent. A user
+ // rebuilding or reinstalling while we download always wins.
+ storedLocale := preferences.LocaleAuto
+ if prefs, _ := preferences.Load(); prefs != nil {
+ storedLocale = prefs.Locale
+ }
+ _ = i18n.Init(i18n.Resolve(storedLocale))
+ expected, err := executableDigest(worker)
if err == nil {
- c.LatestVersion = latest
- } else {
- // Preserve any previously-cached LatestVersion so a transient
- // failure doesn't drop a known-good notification target.
- if prev, _ := loadCache(); prev != nil {
- c.LatestVersion = prev.LatestVersion
+ updater := makeUpdater()
+ var closeParent func()
+ updater.beforeInstall, closeParent, err = parentExitWaiter(parent)
+ if err != nil {
+ c.Status, c.Error = "failed", err.Error()
+ _ = saveCache(c)
+ return true
+ }
+ defer closeParent()
+ var latest string
+ latest, c.InstalledVersion, err = updater.update(ctx, target, version, expected)
+ if latest != "" {
+ c.LatestVersion = latest
}
}
+ c.Status = "current"
+ if err != nil {
+ c.Status, c.Error = "failed", err.Error()
+ }
+ if c.InstalledVersion != "" {
+ c.Status, c.NotificationPending = "updated", true
+ }
_ = saveCache(c)
+ return true
}
diff --git a/packages/cli/internal/platform/updatecheck/background_test.go b/packages/cli/internal/platform/updatecheck/background_test.go
new file mode 100644
index 0000000..0330f7d
--- /dev/null
+++ b/packages/cli/internal/platform/updatecheck/background_test.go
@@ -0,0 +1,147 @@
+package updatecheck
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/gofrs/flock"
+)
+
+func TestUpdateLaunchIsThrottledAndLocked(t *testing.T) {
+ withIsolatedCache(t)
+ target := filepath.Join(t.TempDir(), "one")
+ calls := 0
+ launch := func(got, dir string) error {
+ if got != target || dir == "" {
+ t.Fatalf("launch arguments: %s %s", got, dir)
+ }
+ calls++
+ return nil
+ }
+ startUpdate("1.0.0", target, launch)
+ startUpdate("1.0.0", target, launch)
+ if calls != 1 {
+ t.Fatalf("launches = %d", calls)
+ }
+ c, err := loadCache()
+ if err != nil || c.Status != "checking" {
+ t.Fatalf("cache = %#v, %v", c, err)
+ }
+ c.LastChecked = time.Now().Add(-48 * time.Hour)
+ if err := saveCache(c); err != nil {
+ t.Fatal(err)
+ }
+ path, _ := cachePath()
+ lock := flock.New(path + ".lock")
+ if err := lock.Lock(); err != nil {
+ t.Fatal(err)
+ }
+ startUpdate("1.0.0", target, launch)
+ if err := lock.Unlock(); err != nil {
+ t.Fatal(err)
+ }
+ if calls != 1 {
+ t.Fatal("concurrent updater was launched")
+ }
+ startUpdate("1.0.0", target, launch)
+ if calls != 2 {
+ t.Fatal("stale update did not retry")
+ }
+}
+
+func TestFailedLaunchPreservesInstalledProgramAndRateLimits(t *testing.T) {
+ withIsolatedCache(t)
+ target := filepath.Join(t.TempDir(), "one")
+ if err := os.WriteFile(target, []byte("installed"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ launch := func(string, string) error { calls++; return errors.New("access denied") }
+ startUpdate("1.0.0", target, launch)
+ startUpdate("1.0.0", target, launch)
+ c, err := loadCache()
+ if err != nil || c.Status != "failed" || c.Error != "access denied" || calls != 1 {
+ t.Fatalf("cache=%#v calls=%d err=%v", c, calls, err)
+ }
+ raw, err := os.ReadFile(target)
+ if err != nil || string(raw) != "installed" {
+ t.Fatalf("target changed: %q, %v", raw, err)
+ }
+}
+
+func TestWorkerEntryRejectsDevelopmentBuildsAndInvalidCalls(t *testing.T) {
+ withIsolatedCache(t)
+ if RunWorker("1.0.0", []string{"--help"}) {
+ t.Fatal("ordinary invocation consumed")
+ }
+ before := buildChannel
+ t.Cleanup(func() { buildChannel = before })
+ buildChannel = "development"
+ if !RunWorker("1.0.0", []string{workerCommand, "/unused", "1"}) {
+ t.Fatal("worker invocation not consumed")
+ }
+ buildChannel = "release"
+ if !RunWorker("1.0.0-local.abc1234", []string{workerCommand, "/unused", "1"}) {
+ t.Fatal("local invocation not consumed")
+ }
+ c, err := loadCache()
+ if err != nil || c != nil {
+ t.Fatalf("disabled worker changed cache: %#v, %v", c, err)
+ }
+}
+
+func TestDetachedWorkerSurvivesLauncher(t *testing.T) {
+ marker := filepath.Join(t.TempDir(), "finished")
+ launcher := exec.Command(os.Args[0], "-test.run=^TestDetachedWorkerHelper$")
+ launcher.Env = append(os.Environ(), "ONE_UPDATE_TEST_ROLE=launcher", "ONE_UPDATE_TEST_MARKER="+marker)
+ if raw, err := launcher.CombinedOutput(); err != nil {
+ t.Fatalf("launcher: %v %s", err, raw)
+ }
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ raw, err := os.ReadFile(marker)
+ if err == nil {
+ if string(raw) != "finished" {
+ t.Fatalf("worker result: %q", raw)
+ }
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatal("detached updater did not survive its launching command")
+}
+
+func TestDetachedWorkerHelper(t *testing.T) {
+ switch os.Getenv("ONE_UPDATE_TEST_ROLE") {
+ case "launcher":
+ worker := exec.Command(os.Args[0], "-test.run=^TestDetachedWorkerHelper$")
+ worker.Env = append(os.Environ(), "ONE_UPDATE_TEST_ROLE=worker", "ONE_UPDATE_TEST_PARENT="+strconv.Itoa(os.Getpid()))
+ detachWorker(worker)
+ if err := worker.Start(); err != nil {
+ os.Exit(2)
+ }
+ _ = worker.Process.Release()
+ os.Exit(0)
+ case "worker":
+ parent, _ := strconv.Atoi(os.Getenv("ONE_UPDATE_TEST_PARENT"))
+ wait, closeParent, err := parentExitWaiter(parent)
+ if err != nil {
+ os.Exit(3)
+ }
+ if err := wait(); err != nil {
+ closeParent()
+ os.Exit(4)
+ }
+ closeParent()
+ time.Sleep(100 * time.Millisecond)
+ if err := os.WriteFile(os.Getenv("ONE_UPDATE_TEST_MARKER"), []byte("finished"), 0o600); err != nil {
+ os.Exit(5)
+ }
+ os.Exit(0)
+ }
+}
diff --git a/packages/cli/internal/platform/updatecheck/cache.go b/packages/cli/internal/platform/updatecheck/cache.go
index b6640d5..e30573a 100644
--- a/packages/cli/internal/platform/updatecheck/cache.go
+++ b/packages/cli/internal/platform/updatecheck/cache.go
@@ -24,9 +24,15 @@ import (
// the door open for future additions (release notes URL, breaking-change
// flag, channel) without forcing a migration today.
type Cache struct {
- Schema string `json:"schema"`
- LastChecked time.Time `json:"last_checked"`
- LatestVersion string `json:"latest_version,omitempty"`
+ Schema string `json:"schema"`
+ LastChecked time.Time `json:"last_checked"`
+ LatestVersion string `json:"latest_version,omitempty"`
+ CurrentVersion string `json:"current_version,omitempty"`
+ TargetPath string `json:"target_path,omitempty"`
+ Status string `json:"status,omitempty"`
+ InstalledVersion string `json:"installed_version,omitempty"`
+ Error string `json:"error,omitempty"`
+ NotificationPending bool `json:"notification_pending,omitempty"`
}
const cacheSchema = "one-cli/update-check/v1"
diff --git a/packages/cli/internal/platform/updatecheck/cache_test.go b/packages/cli/internal/platform/updatecheck/cache_test.go
index e268bf6..b4f0a89 100644
--- a/packages/cli/internal/platform/updatecheck/cache_test.go
+++ b/packages/cli/internal/platform/updatecheck/cache_test.go
@@ -14,6 +14,7 @@ func withIsolatedCache(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
t.Setenv("XDG_CACHE_HOME", tmp)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config"))
t.Setenv("HOME", tmp)
return tmp
}
diff --git a/packages/cli/internal/platform/updatecheck/checker.go b/packages/cli/internal/platform/updatecheck/checker.go
index 6f13a20..520df4e 100644
--- a/packages/cli/internal/platform/updatecheck/checker.go
+++ b/packages/cli/internal/platform/updatecheck/checker.go
@@ -6,11 +6,12 @@ package updatecheck
import (
"context"
- "fmt"
"io"
"net/http"
"strings"
"time"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
)
// latestEndpoint is the source of truth. GitHub redirects this URL to
@@ -27,9 +28,13 @@ const fetchTimeout = 5 * time.Second
// surfaced as an error; the caller treats all errors as "skip cache update,
// try again next time".
func fetchLatest(ctx context.Context, currentVersion string) (string, error) {
+ return fetchLatestUsing(ctx, http.DefaultClient, latestEndpoint, currentVersion)
+}
+
+func fetchLatestUsing(ctx context.Context, client *http.Client, endpoint, currentVersion string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestEndpoint, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
@@ -37,13 +42,13 @@ func fetchLatest(ctx context.Context, currentVersion string) (string, error) {
// useful for traffic attribution and lets them block buggy versions
// later if needed. Carries no PII.
req.Header.Set("User-Agent", "one-cli/"+currentVersion)
- resp, err := http.DefaultClient.Do(req)
+ resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("updatecheck: %s returned %d", latestEndpoint, resp.StatusCode)
+ return "", i18n.Errorf("update.http_failed", resp.StatusCode)
}
if v := versionFromReleasePath(resp.Request.URL.Path); v != "" {
return v, nil
@@ -56,7 +61,7 @@ func fetchLatest(ctx context.Context, currentVersion string) (string, error) {
}
v := normalizeTag(strings.TrimSpace(string(raw)))
if v == "" {
- return "", fmt.Errorf("updatecheck: %s returned unparseable %q", latestEndpoint, string(raw))
+ return "", i18n.Errorf("update.version_invalid", string(raw))
}
return v, nil
}
diff --git a/packages/cli/internal/platform/updatecheck/checker_test.go b/packages/cli/internal/platform/updatecheck/checker_test.go
index 113f221..994fc94 100644
--- a/packages/cli/internal/platform/updatecheck/checker_test.go
+++ b/packages/cli/internal/platform/updatecheck/checker_test.go
@@ -8,41 +8,11 @@ import (
"testing"
)
-// fetchAt is a test-only seam: same code path as fetchLatest but lets us
-// point at an httptest server. We can't override the const URL otherwise.
+// Exercise the production fetch path against a local server.
func fetchAt(ctx context.Context, url, currentVersion string) (string, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return "", err
- }
- req.Header.Set("User-Agent", "one-cli/"+currentVersion)
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return "", &httpError{status: resp.StatusCode}
- }
- if v := versionFromReleasePath(resp.Request.URL.Path); v != "" {
- return v, nil
- }
- buf := make([]byte, 64)
- n, _ := resp.Body.Read(buf)
- v := normalizeTag(strings.TrimSpace(string(buf[:n])))
- if v == "" {
- return "", &httpError{status: resp.StatusCode, body: string(buf[:n])}
- }
- return v, nil
+ return fetchLatestUsing(ctx, http.DefaultClient, url, currentVersion)
}
-type httpError struct {
- status int
- body string
-}
-
-func (e *httpError) Error() string { return "fetch failed" }
-
func TestFetch_Happy(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.Header.Get("User-Agent"), "one-cli/") {
diff --git a/packages/cli/internal/platform/updatecheck/install.go b/packages/cli/internal/platform/updatecheck/install.go
new file mode 100644
index 0000000..9d34b71
--- /dev/null
+++ b/packages/cli/internal/platform/updatecheck/install.go
@@ -0,0 +1,356 @@
+package updatecheck
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bufio"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+const releaseBaseURL = "https://github.com/1cli-team/one-cli/releases/download"
+const maxArchiveSize = 256 << 20
+const maxExecutableSize = 512 << 20
+
+type updater struct {
+ client *http.Client
+ latestURL, releaseURL string
+ goos, goarch string
+ probe func(context.Context, string, string) error
+ beforeInstall func() error
+}
+
+func defaultUpdater() updater {
+ return updater{
+ client: &http.Client{Timeout: 2 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if req.URL.Scheme != "https" || len(via) >= 10 {
+ return i18n.Errorf("update.redirect_invalid")
+ }
+ return nil
+ }},
+ latestURL: latestEndpoint, releaseURL: releaseBaseURL,
+ goos: runtime.GOOS, goarch: runtime.GOARCH,
+ probe: probeExecutable,
+ }
+}
+
+func (u updater) update(ctx context.Context, target, current, expected string) (latest, installed string, err error) {
+ if !isStableRelease(current) {
+ return "", "", i18n.Errorf("update.release_required")
+ }
+ if err = unchangedExecutable(target, expected); err != nil {
+ return
+ }
+ info, err := os.Stat(target)
+ if err != nil {
+ return "", "", err
+ }
+ archiveName := "one-cli_" + u.goos + "_" + u.goarch
+ switch {
+ case (u.goos == "linux" || u.goos == "darwin") && (u.goarch == "amd64" || u.goarch == "arm64"):
+ archiveName += ".tar.gz"
+ case u.goos == "windows" && u.goarch == "amd64":
+ archiveName += ".zip"
+ default:
+ err = i18n.Errorf("update.platform_unsupported", u.goos, u.goarch)
+ return
+ }
+ latest, err = fetchLatestUsing(ctx, u.client, u.latestURL, current)
+ if err != nil || !isNewer(latest, current) {
+ return
+ }
+ base := u.releaseURL + "/" + latest + "/"
+ checksum, err := u.checksum(ctx, base+"checksums.txt", archiveName)
+ if err != nil {
+ return latest, "", err
+ }
+ archive, err := os.CreateTemp(filepath.Dir(target), ".one-download-*")
+ if err != nil {
+ return latest, "", err
+ }
+ archivePath := archive.Name()
+ defer os.Remove(archivePath)
+ hash := sha256.New()
+ err = u.download(ctx, base+archiveName, io.MultiWriter(archive, hash), maxArchiveSize)
+ closeErr := archive.Close()
+ if err != nil {
+ return latest, "", err
+ }
+ if closeErr != nil {
+ return latest, "", closeErr
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != checksum {
+ return latest, "", i18n.Errorf("update.checksum_mismatch", archiveName)
+ }
+ staged, err := os.CreateTemp(filepath.Dir(target), ".one-next-*")
+ if err != nil {
+ return latest, "", err
+ }
+ stagedPath := staged.Name()
+ // Windows requires an .exe suffix to execute the verified candidate.
+ if u.goos == "windows" {
+ if err = staged.Close(); err != nil {
+ os.Remove(stagedPath)
+ return latest, "", err
+ }
+ os.Remove(stagedPath)
+ stagedPath += ".exe"
+ staged, err = os.OpenFile(stagedPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return latest, "", err
+ }
+ }
+ defer os.Remove(stagedPath)
+ err = extractExecutable(archivePath, u.goos, staged)
+ if err == nil {
+ err = staged.Chmod(info.Mode().Perm())
+ }
+ if err == nil {
+ err = staged.Sync()
+ }
+ closeErr = staged.Close()
+ if err != nil {
+ return latest, "", err
+ }
+ if closeErr != nil {
+ return latest, "", closeErr
+ }
+ if err = u.probe(ctx, stagedPath, latest); err != nil {
+ return latest, "", err
+ }
+ if u.beforeInstall != nil {
+ if err = u.beforeInstall(); err != nil {
+ return latest, "", err
+ }
+ }
+ if err = unchangedExecutable(target, expected); err != nil {
+ return latest, "", err
+ }
+ if err = fsutil.ReplaceFile(stagedPath, target); err != nil {
+ return latest, "", err
+ }
+ _ = fsutil.SyncDir(filepath.Dir(target))
+ return latest, latest, nil
+}
+
+func (u updater) download(ctx context.Context, url string, dst io.Writer, limit int64) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "one-cli/auto-update")
+ resp, err := u.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return i18n.Errorf("update.http_failed", resp.StatusCode)
+ }
+ if resp.ContentLength > limit {
+ return i18n.Errorf("update.download_too_large")
+ }
+ n, err := io.Copy(dst, io.LimitReader(resp.Body, limit+1))
+ if err != nil {
+ return err
+ }
+ if n > limit {
+ return i18n.Errorf("update.download_too_large")
+ }
+ return nil
+}
+
+func (u updater) checksum(ctx context.Context, url, archive string) (string, error) {
+ var buf strings.Builder
+ if err := u.download(ctx, url, &buf, 1<<20); err != nil {
+ return "", err
+ }
+ scanner := bufio.NewScanner(strings.NewReader(buf.String()))
+ result := ""
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != archive {
+ continue
+ }
+ digest, err := hex.DecodeString(fields[0])
+ if err != nil || len(digest) != sha256.Size || result != "" {
+ return "", i18n.Errorf("update.checksum_invalid", archive)
+ }
+ result = strings.ToLower(fields[0])
+ }
+ if err := scanner.Err(); err != nil {
+ return "", err
+ }
+ if result == "" {
+ return "", i18n.Errorf("update.checksum_invalid", archive)
+ }
+ return result, nil
+}
+
+// Only copy the root executable. Archive paths, permissions, links, and other
+// files never become filesystem paths or modify the installation directory.
+func extractExecutable(archive, goos string, dst io.Writer) error {
+ name := "one"
+ found := false
+ copyEntry := func(reader io.Reader, size int64) error {
+ if found || size <= 0 || size > maxExecutableSize {
+ return i18n.Errorf("update.archive_invalid")
+ }
+ found = true
+ n, err := io.Copy(dst, io.LimitReader(reader, maxExecutableSize+1))
+ if err != nil {
+ return err
+ }
+ if n != size {
+ return i18n.Errorf("update.archive_invalid")
+ }
+ return nil
+ }
+ if goos == "windows" {
+ name += ".exe"
+ archive, err := zip.OpenReader(archive)
+ if err != nil {
+ return err
+ }
+ defer archive.Close()
+ for _, file := range archive.File {
+ if strings.TrimPrefix(file.Name, "./") != name {
+ continue
+ }
+ if !file.Mode().IsRegular() || file.UncompressedSize64 > maxExecutableSize {
+ return i18n.Errorf("update.archive_invalid")
+ }
+ reader, err := file.Open()
+ if err != nil {
+ return err
+ }
+ err = copyEntry(reader, int64(file.UncompressedSize64))
+ closeErr := reader.Close()
+ if err != nil {
+ return err
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ }
+ } else {
+ file, err := os.Open(archive)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ gzipReader, err := gzip.NewReader(file)
+ if err != nil {
+ return err
+ }
+ defer gzipReader.Close()
+ reader := tar.NewReader(io.LimitReader(gzipReader, maxExecutableSize+1))
+ for {
+ header, err := reader.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ if strings.TrimPrefix(header.Name, "./") != name {
+ continue
+ }
+ if header.Typeflag != tar.TypeReg {
+ return i18n.Errorf("update.archive_invalid")
+ }
+ if err = copyEntry(reader, header.Size); err != nil {
+ return err
+ }
+ }
+ }
+ if !found {
+ return i18n.Errorf("update.archive_invalid")
+ }
+ return nil
+}
+
+func executableName() string {
+ if runtime.GOOS == "windows" {
+ return "one.exe"
+ }
+ return "one"
+}
+
+func executableDigest(path string) (string, error) {
+ info, err := os.Lstat(path)
+ if err != nil {
+ return "", err
+ }
+ if !info.Mode().IsRegular() {
+ return "", i18n.Errorf("update.target_changed", path)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ hash := sha256.New()
+ if _, err := io.Copy(hash, file); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(hash.Sum(nil)), nil
+}
+
+func unchangedExecutable(path, expected string) error {
+ actual, err := executableDigest(path)
+ if err != nil {
+ return err
+ }
+ if expected == "" || actual != expected {
+ return i18n.Errorf("update.target_changed", path)
+ }
+ return nil
+}
+
+func copyExecutable(source, target string) error {
+ in, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(out, in)
+ closeErr := out.Close()
+ if err != nil {
+ return err
+ }
+ return closeErr
+}
+
+func probeExecutable(ctx context.Context, path, version string) error {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, path, "--version")
+ cmd.Env = append(os.Environ(), "CI=true")
+ raw, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("%s: %w", path, err)
+ }
+ if normalizeTag(string(raw)) != version {
+ return i18n.Errorf("update.version_mismatch", strings.TrimSpace(string(raw)), version)
+ }
+ return nil
+}
diff --git a/packages/cli/internal/platform/updatecheck/install_test.go b/packages/cli/internal/platform/updatecheck/install_test.go
new file mode 100644
index 0000000..ba0d2a5
--- /dev/null
+++ b/packages/cli/internal/platform/updatecheck/install_test.go
@@ -0,0 +1,219 @@
+package updatecheck
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testArchive(t *testing.T, goos string, names []string, body []byte, symlink bool) []byte {
+ t.Helper()
+ var out bytes.Buffer
+ if goos == "windows" {
+ writer := zip.NewWriter(&out)
+ for _, name := range names {
+ header := &zip.FileHeader{Name: name, Method: zip.Deflate}
+ mode := os.FileMode(0o755)
+ if symlink {
+ mode |= os.ModeSymlink
+ }
+ header.SetMode(mode)
+ entry, err := writer.CreateHeader(header)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := entry.Write(body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ compressed := gzip.NewWriter(&out)
+ writer := tar.NewWriter(compressed)
+ for _, name := range names {
+ header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}
+ if symlink {
+ header.Typeflag, header.Linkname, header.Size = tar.TypeSymlink, "elsewhere", 0
+ }
+ if err := writer.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if !symlink {
+ if _, err := writer.Write(body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := compressed.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return out.Bytes()
+}
+
+func TestAutomaticUpdateInstallsOnlyVerifiedReleases(t *testing.T) {
+ for _, tc := range []struct{ name, goos, latest, failure string }{
+ {"tar success", "linux", "v1.2.3", ""},
+ {"zip success", "windows", "v1.2.3", ""},
+ {"same version", "linux", "v1.0.0", ""},
+ {"older version", "linux", "v0.9.0", ""},
+ {"prerelease feed", "linux", "v1.2.3-rc.1", "prerelease"},
+ {"checksum mismatch", "linux", "v1.2.3", "checksum"},
+ {"missing checksum", "linux", "v1.2.3", "missing-checksum"},
+ {"duplicate checksum", "linux", "v1.2.3", "duplicate-checksum"},
+ {"download unavailable", "linux", "v1.2.3", "download"},
+ {"wrong executable version", "linux", "v1.2.3", "probe"},
+ {"concurrent developer build", "linux", "v1.2.3", "changed"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "one")
+ old, candidate := []byte("installed release"), []byte("verified new release")
+ if err := os.WriteFile(target, old, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ expected, err := executableDigest(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ binaryName, ext := "one", ".tar.gz"
+ if tc.goos == "windows" {
+ binaryName, ext = "one.exe", ".zip"
+ }
+ name := "one-cli_" + tc.goos + "_amd64" + ext
+ archive := testArchive(t, tc.goos, []string{binaryName}, candidate, false)
+ digest := fmt.Sprintf("%x", sha256.Sum256(archive))
+ probed, waited, downloaded := false, false, false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/latest":
+ io.WriteString(w, tc.latest)
+ case "/v1.2.3/checksums.txt":
+ entry := digest + " " + name + "\n"
+ switch tc.failure {
+ case "checksum":
+ entry = strings.Repeat("0", 64) + " " + name + "\n"
+ case "missing-checksum":
+ entry = digest + " other.tar.gz\n"
+ case "duplicate-checksum":
+ entry += entry
+ }
+ io.WriteString(w, entry)
+ case "/v1.2.3/" + name:
+ downloaded = true
+ if tc.failure == "download" {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ w.Write(archive)
+ default:
+ t.Errorf("unexpected request %s", r.URL.Path)
+ w.WriteHeader(404)
+ }
+ }))
+ defer server.Close()
+ u := updater{client: server.Client(), latestURL: server.URL + "/latest", releaseURL: server.URL, goos: tc.goos, goarch: "amd64",
+ probe: func(_ context.Context, path, version string) error {
+ probed = true
+ raw, err := os.ReadFile(path)
+ if err != nil || !bytes.Equal(raw, candidate) || version != tc.latest {
+ t.Fatalf("candidate = %q, version = %s, err = %v", raw, version, err)
+ }
+ if tc.failure == "probe" {
+ return fmt.Errorf("unexpected version")
+ }
+ if tc.failure == "changed" {
+ return os.WriteFile(target, []byte("new developer build"), 0o700)
+ }
+ return nil
+ }, beforeInstall: func() error { waited = true; return nil },
+ }
+ _, installed, err := u.update(context.Background(), target, "1.0.0", expected)
+ if (err != nil) != (tc.failure != "") {
+ t.Fatalf("error = %v", err)
+ }
+ raw, readErr := os.ReadFile(target)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ want := old
+ success := tc.latest == "v1.2.3" && tc.failure == ""
+ if success {
+ want = candidate
+ if installed != tc.latest || !probed || !waited {
+ t.Fatalf("update not completed: %q %v %v", installed, probed, waited)
+ }
+ } else if installed != "" {
+ t.Fatalf("failed/skipped update reported installed %s", installed)
+ }
+ if tc.failure == "changed" {
+ want = []byte("new developer build")
+ }
+ if !bytes.Equal(raw, want) {
+ t.Fatalf("installed content = %q, want %q", raw, want)
+ }
+ if (tc.latest == "v1.0.0" || tc.latest == "v0.9.0") && (probed || downloaded) {
+ t.Fatal("same/older release was downloaded")
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 1 {
+ t.Fatalf("temporary downloads leaked: %v %v", entries, err)
+ }
+ })
+ }
+}
+
+func TestUpdateArchiveRejectsUnsafeOrAmbiguousExecutables(t *testing.T) {
+ for _, goos := range []string{"linux", "windows"} {
+ name := "one"
+ if goos == "windows" {
+ name = "one.exe"
+ }
+ for _, tc := range []struct {
+ name string
+ names []string
+ symlink bool
+ }{
+ {"path traversal", []string{"../" + name}, false},
+ {"duplicate", []string{name, name}, false},
+ {"symlink", []string{name}, true},
+ {"missing", []string{"README.md"}, false},
+ } {
+ t.Run(goos+"/"+tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "archive")
+ if err := os.WriteFile(path, testArchive(t, goos, tc.names, []byte("candidate"), tc.symlink), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+ if err := extractExecutable(path, goos, &out); err == nil {
+ t.Fatal("invalid archive accepted")
+ }
+ })
+ }
+ }
+}
+
+func TestUpdateSkipsDevelopmentVersionsBeforeNetwork(t *testing.T) {
+ u := updater{} // accessing the client or probe would panic
+ for _, version := range developmentVersions {
+ if _, installed, err := u.update(context.Background(), "unused", version, "unused"); err == nil || installed != "" {
+ t.Fatalf("%q: %q %v", version, installed, err)
+ }
+ }
+}
diff --git a/packages/cli/internal/platform/updatecheck/notify.go b/packages/cli/internal/platform/updatecheck/notify.go
index d512aac..66850ad 100644
--- a/packages/cli/internal/platform/updatecheck/notify.go
+++ b/packages/cli/internal/platform/updatecheck/notify.go
@@ -9,6 +9,7 @@ import (
"runtime"
"charm.land/lipgloss/v2"
+ "github.com/gofrs/flock"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output"
@@ -38,18 +39,29 @@ func Notify(currentVersion string) {
if shouldSkip(currentVersion) {
return
}
- // Block briefly if MaybeRefreshAsync started a fetch that hasn't
- // finished yet — necessary for short-lived commands (--help /
- // --version) that would otherwise outrun the goroutine and never
- // see a populated cache.
- waitForRefresh()
c, err := loadCache()
- if err != nil || c == nil || c.LatestVersion == "" {
+ if err != nil || c == nil {
return
}
- if !isNewer(c.LatestVersion, currentVersion) {
+ if c.TargetPath != "" {
+ target, err := os.Executable()
+ if err != nil || target != c.TargetPath {
+ return
+ }
+ }
+ if c.Status == "checking" {
+ return
+ }
+ if c.Status == "updated" && c.NotificationPending && claimNotification(c) {
+ lipgloss.Fprintln(os.Stderr, i18n.Tf("update.installed", c.InstalledVersion))
return
}
+ if !isNewer(c.LatestVersion, currentVersion) || c.Status == "updated" {
+ return
+ }
+ if c.Status == "failed" && c.Error != "" {
+ lipgloss.Fprintln(os.Stderr, i18n.Tf("update.failed", c.Error))
+ }
printWarning(os.Stderr, c.LatestVersion, currentVersion)
}
@@ -74,13 +86,16 @@ func printWarning(w *os.File, latest, current string) {
// The order is: cheapest checks first, so the common case (CI, JSON
// output) never touches the filesystem.
func shouldSkip(currentVersion string) bool {
+ if buildChannel != "release" {
+ return true
+ }
if isCI() {
return true
}
if !output.IsTTY() {
return true
}
- if currentVersion == "" || currentVersion == "0.0.0-dev" {
+ if !isStableRelease(currentVersion) {
return true
}
return false
@@ -98,3 +113,23 @@ func isCI() bool {
}
return false
}
+
+// Only one interactive process consumes a completed update notification.
+func claimNotification(c *Cache) bool {
+ path, err := cachePath()
+ if err != nil {
+ return false
+ }
+ lock := flock.New(path + ".lock")
+ locked, err := lock.TryLock()
+ if err != nil || !locked {
+ return false
+ }
+ defer lock.Unlock()
+ current, err := loadCache()
+ if err != nil || current == nil || !current.NotificationPending || current.TargetPath != c.TargetPath || current.InstalledVersion != c.InstalledVersion {
+ return false
+ }
+ current.NotificationPending = false
+ return saveCache(current) == nil
+}
diff --git a/packages/cli/internal/platform/updatecheck/notify_test.go b/packages/cli/internal/platform/updatecheck/notify_test.go
index 240e4c3..9f64b6f 100644
--- a/packages/cli/internal/platform/updatecheck/notify_test.go
+++ b/packages/cli/internal/platform/updatecheck/notify_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output"
)
@@ -17,6 +18,9 @@ import (
// internal/platform/output doesn't surface a getter for the current mode.
func withTTY(t *testing.T) {
t.Helper()
+ originalChannel := buildChannel
+ buildChannel = "release"
+ t.Cleanup(func() { buildChannel = originalChannel })
output.SetMode(output.ModeTTY)
t.Cleanup(func() { output.SetMode(output.ModeAuto) })
}
@@ -40,6 +44,7 @@ func TestShouldSkip_CI(t *testing.T) {
}
func TestShouldSkip_NonTTY(t *testing.T) {
+ withTTY(t)
clearCIEnv(t)
output.SetMode(output.ModeJSON)
t.Cleanup(func() { output.SetMode(output.ModeAuto) })
@@ -48,22 +53,63 @@ func TestShouldSkip_NonTTY(t *testing.T) {
}
}
+var developmentVersions = []string{
+ "", "dev", "unknown", "0.0.0", "v0.0.0", "0.0.0-dev",
+ "0.0.0-dev-local.abc1234", "0.0.0-dev-local.abc1234.dirty",
+ "1.2.3-local.abc1234", "1.2.3-local.abc1234.dirty",
+ "v1.2.3-dev", "1.2.3-SNAPSHOT-abc1234", "v1.2.3-rc.1",
+ "1.2.3+local.abc1234", "v1", "1.2", "01.2.3", "1.2.3.4",
+}
+
func TestShouldSkip_DevVersion(t *testing.T) {
withTTY(t)
clearCIEnv(t)
- if !shouldSkip("0.0.0-dev") {
- t.Errorf("expected skip for dev build")
+ for _, version := range developmentVersions {
+ if !shouldSkip(version) {
+ t.Errorf("expected skip for development/unknown build %q", version)
+ }
+ }
+}
+
+func TestDevelopmentBuildsNeverRefreshOrNotify(t *testing.T) {
+ withTTY(t)
+ clearCIEnv(t)
+ withIsolatedCache(t)
+ cached := &Cache{LastChecked: time.Now().Add(-48 * time.Hour).UTC(), LatestVersion: "v99.0.0"}
+ if err := saveCache(cached); err != nil {
+ t.Fatal(err)
+ }
+ path, err := cachePath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, version := range developmentVersions {
+ MaybeRefreshAsync(version)
+
+ if got := captureStderr(t, func() { Notify(version) }); got != "" {
+ t.Fatalf("development build %q printed an update: %q", version, got)
+ }
+ }
+ after, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
}
- if !shouldSkip("") {
- t.Errorf("expected skip for empty version")
+ if !bytes.Equal(before, after) {
+ t.Fatal("development update check rewrote the cache")
}
}
func TestShouldSkip_HappyPath(t *testing.T) {
withTTY(t)
clearCIEnv(t)
- if shouldSkip("v0.8.0") {
- t.Errorf("expected no skip for plain TTY interactive run")
+ for _, version := range []string{"v0.8.0", "0.8.0", "1.2.3", "v10.20.30"} {
+ if shouldSkip(version) {
+ t.Errorf("expected no skip for stable release %q", version)
+ }
}
}
@@ -156,3 +202,39 @@ func captureStderr(t *testing.T, fn func()) string {
_ = w.Close()
return <-done
}
+
+func TestDevelopmentBuildWithReleaseVersionStillSkips(t *testing.T) {
+ withTTY(t)
+ clearCIEnv(t)
+ buildChannel = "development"
+ if !shouldSkip("1.2.3") {
+ t.Fatal("version override enabled updates in a source build")
+ }
+}
+
+func TestNotifyInstalledUpdateOnceInBothLanguages(t *testing.T) {
+ withTTY(t)
+ clearCIEnv(t)
+ withIsolatedCache(t)
+ target, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) })
+ for _, locale := range []string{"en-US", "zh-CN"} {
+ if err := i18n.Init(locale); err != nil {
+ t.Fatal(err)
+ }
+ c := &Cache{LastChecked: time.Now(), TargetPath: target, CurrentVersion: "1.0.0", LatestVersion: "v1.2.3", InstalledVersion: "v1.2.3", Status: "updated", NotificationPending: true}
+ if err := saveCache(c); err != nil {
+ t.Fatal(err)
+ }
+ got := captureStderr(t, func() { Notify("1.2.3") })
+ if !strings.Contains(got, i18n.Tf("update.installed", "v1.2.3")) {
+ t.Fatalf("%s notification: %q", locale, got)
+ }
+ if got := captureStderr(t, func() { Notify("1.2.3") }); got != "" {
+ t.Fatalf("notification repeated: %q", got)
+ }
+ }
+}
diff --git a/packages/cli/internal/platform/updatecheck/semver.go b/packages/cli/internal/platform/updatecheck/semver.go
index 0a317b3..3412a97 100644
--- a/packages/cli/internal/platform/updatecheck/semver.go
+++ b/packages/cli/internal/platform/updatecheck/semver.go
@@ -72,13 +72,33 @@ func parseTriple(v string) ([3]int, bool) {
return out, true
}
-// normalizeTag re-shapes a release tag into a canonical `vX.Y.Z` (no
-// pre-release suffix). Empty / unparseable input → "" so the caller can
-// short-circuit cache writes.
+// normalizeTag accepts only stable, complete release tags from the update feed.
func normalizeTag(raw string) string {
- t, ok := parseTriple(raw)
- if !ok {
+ raw = strings.TrimSpace(raw)
+ if !isStableRelease(raw) {
return ""
}
- return "v" + strconv.Itoa(t[0]) + "." + strconv.Itoa(t[1]) + "." + strconv.Itoa(t[2])
+ return "v" + strings.TrimPrefix(raw, "v")
+}
+
+// isStableRelease admits only complete release versions. Development, local,
+// snapshot, prerelease, and unknown builds must never join the update pipeline.
+func isStableRelease(version string) bool {
+ version = strings.TrimPrefix(version, "v")
+ parts := strings.Split(version, ".")
+ if len(parts) != 3 {
+ return false
+ }
+ for _, part := range parts {
+ if part == "" || (len(part) > 1 && part[0] == '0') {
+ return false
+ }
+ for _, r := range part {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ }
+ triple, ok := parseTriple(version)
+ return ok && triple != [3]int{}
}
diff --git a/packages/cli/internal/platform/updatecheck/semver_test.go b/packages/cli/internal/platform/updatecheck/semver_test.go
index 78646a0..adad72e 100644
--- a/packages/cli/internal/platform/updatecheck/semver_test.go
+++ b/packages/cli/internal/platform/updatecheck/semver_test.go
@@ -57,9 +57,9 @@ func TestNormalizeTag(t *testing.T) {
{"0.8.0", "v0.8.0"},
{"v0.8.0\n", "v0.8.0"},
{" v0.8.0 ", "v0.8.0"},
- {"v0.8.0-rc1", "v0.8.0"}, // suffix stripped
- {"v1", "v1.0.0"}, // missing segments filled
- {"v1.2", "v1.2.0"},
+ {"v0.8.0-rc1", ""}, // never turn a prerelease into a stable download
+ {"v1", ""}, // incomplete versions are not release tags
+ {"v1.2", ""},
{"", ""},
{"abc", ""},
{"v9.9.9.9", ""}, // too many segments
diff --git a/packages/cli/internal/platform/updatecheck/worker_integration_test.go b/packages/cli/internal/platform/updatecheck/worker_integration_test.go
new file mode 100644
index 0000000..03e3b74
--- /dev/null
+++ b/packages/cli/internal/platform/updatecheck/worker_integration_test.go
@@ -0,0 +1,126 @@
+package updatecheck
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+// The copied test executable acts as the release binary in the subprocess
+// test. Endpoint overrides and version replies exist only in this test entry.
+func TestMain(m *testing.M) {
+ if os.Getenv("ONE_UPDATE_TEST_INTEGRATION") == "1" {
+ if len(os.Args) == 2 && os.Args[1] == "--version" {
+ fmt.Println("1.2.3")
+ os.Exit(0)
+ }
+ buildChannel = "release"
+ if runWorker("1.0.0", os.Args[1:], func() updater {
+ u := defaultUpdater()
+ u.client = http.DefaultClient
+ u.latestURL = os.Getenv("ONE_UPDATE_TEST_SERVER") + "/latest"
+ u.releaseURL = os.Getenv("ONE_UPDATE_TEST_SERVER")
+ return u
+ }) {
+ os.Exit(0)
+ }
+ }
+ os.Exit(m.Run())
+}
+
+func TestBackgroundWorkerCompletesAfterCommandExits(t *testing.T) {
+ withIsolatedCache(t)
+ clearCIEnv(t)
+ target := filepath.Join(t.TempDir(), executableName())
+ if err := copyExecutable(os.Args[0], target); err != nil {
+ t.Fatal(err)
+ }
+ payload, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ archive := testArchive(t, runtime.GOOS, []string{executableName()}, payload, false)
+ ext := ".tar.gz"
+ if runtime.GOOS == "windows" {
+ ext = ".zip"
+ }
+ name := "one-cli_" + runtime.GOOS + "_" + runtime.GOARCH + ext
+ // Do not let the archive request finish until the foreground command exits.
+ // An in-process goroutine would die without installing this release.
+ resume := make(chan struct{})
+ resumed := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/latest":
+ fmt.Fprintln(w, "v1.2.3")
+ case "/v1.2.3/checksums.txt":
+ fmt.Fprintf(w, "%x %s\n", sha256.Sum256(archive), name)
+ case "/v1.2.3/" + name:
+ select {
+ case <-resume:
+ case <-r.Context().Done():
+ return
+ }
+ w.Write(archive)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer func() {
+ if !resumed {
+ close(resume)
+ }
+ server.Close()
+ }()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ launcher := exec.CommandContext(ctx, target, "-test.run=^TestUpdateIntegrationLauncher$")
+ launcher.Env = append(os.Environ(), "ONE_UPDATE_TEST_INTEGRATION=1", "ONE_UPDATE_TEST_SERVER="+server.URL)
+ if raw, err := launcher.CombinedOutput(); err != nil {
+ t.Fatalf("foreground command: %v %s", err, raw)
+ }
+ close(resume)
+ resumed = true
+ deadline := time.Now().Add(15 * time.Second)
+ for time.Now().Before(deadline) {
+ c, err := loadCache()
+ if err == nil && c != nil {
+ if c.Status == "failed" {
+ t.Fatalf("background update failed: %s", c.Error)
+ }
+ if c.Status == "updated" {
+ if c.InstalledVersion != "v1.2.3" || !c.NotificationPending {
+ t.Fatalf("completion state: %#v", c)
+ }
+ probe := exec.Command(target, "--version")
+ probe.Env = append(os.Environ(), "ONE_UPDATE_TEST_INTEGRATION=1")
+ if raw, err := probe.Output(); err != nil || string(raw) != "1.2.3\n" {
+ t.Fatalf("installed binary: %q %v", raw, err)
+ }
+ return
+ }
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("update did not complete after the initiating command exited")
+}
+
+func TestUpdateIntegrationLauncher(t *testing.T) {
+ if os.Getenv("ONE_UPDATE_TEST_INTEGRATION") != "1" {
+ return
+ }
+ target, err := os.Executable()
+ if err != nil {
+ os.Exit(2)
+ }
+ startUpdate("1.0.0", target, launchWorker)
+ os.Exit(0)
+}
diff --git a/packages/cli/internal/platform/updatecheck/worker_unix.go b/packages/cli/internal/platform/updatecheck/worker_unix.go
new file mode 100644
index 0000000..589da78
--- /dev/null
+++ b/packages/cli/internal/platform/updatecheck/worker_unix.go
@@ -0,0 +1,15 @@
+//go:build !windows
+
+package updatecheck
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func detachWorker(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} }
+
+// Unix permits atomically replacing an executable while older processes run.
+func parentExitWaiter(int) (func() error, func(), error) {
+ return func() error { return nil }, func() {}, nil
+}
diff --git a/packages/cli/internal/platform/updatecheck/worker_windows.go b/packages/cli/internal/platform/updatecheck/worker_windows.go
new file mode 100644
index 0000000..ec981f7
--- /dev/null
+++ b/packages/cli/internal/platform/updatecheck/worker_windows.go
@@ -0,0 +1,32 @@
+//go:build windows
+
+package updatecheck
+
+import (
+ "errors"
+ "os/exec"
+ "syscall"
+
+ "golang.org/x/sys/windows"
+)
+
+func detachWorker(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP}
+}
+
+// Open the process handle before downloading so PID reuse cannot make us wait
+// for an unrelated process after a slow transfer.
+func parentExitWaiter(pid int) (func() error, func(), error) {
+ handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
+ if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
+ return func() error { return nil }, func() {}, nil
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ wait := func() error {
+ _, err := windows.WaitForSingleObject(handle, windows.INFINITE)
+ return err
+ }
+ return wait, func() { _ = windows.CloseHandle(handle) }, nil
+}
From 4608e0196c9712f2752cea75db750fd3e6e74195 Mon Sep 17 00:00:00 2001
From: caorushizi <84996057@qq.com>
Date: Tue, 29 Sep 2026 03:30:55 +0800
Subject: [PATCH 14/16] refactor(templates): replace handlebars with runnable
starter projects
---
.github/workflows/ci.yml | 3 +
.gitignore | 4 +
apps/docs/content/docs/en/templates.md | 80 ++++
apps/docs/content/docs/zh/templates.md | 71 ++++
.../2026-09-29-runnable-project-templates.md | 303 +++++++++++++++
mise.toml | 6 +
packages/cli/go.mod | 1 -
packages/cli/go.sum | 2 -
packages/cli/internal/core/template/node.go | 192 ++++++++++
.../cli/internal/core/template/node_test.go | 66 ++++
packages/cli/internal/core/template/now.go | 9 -
packages/cli/internal/core/template/render.go | 344 ++++++++----------
.../cli/internal/core/template/render_test.go | 183 ++++++++++
packages/cli/internal/core/template/spec.go | 104 ++++++
.../cli/internal/core/template/transform.go | 145 ++++++++
.../internal/modules/creation/json_fields.go | 113 +-----
.../creation/runnable_templates_test.go | 99 +++++
.../internal/platform/i18n/locales/en-US.json | 15 +-
.../internal/platform/i18n/locales/zh-CN.json | 15 +-
.../cli/internal/platform/jsonedit/fields.go | 119 ++++++
.../cli/internal/platform/jsonedit/sort.go | 58 +++
.../internal/platform/jsonedit/sort_test.go | 16 +
.../cli/internal/platform/jsonedit/strings.go | 106 ++++++
.../platform/jsonedit/strings_test.go | 39 ++
.../internal/platform/templatefiles/policy.go | 36 ++
.../cli/internal/resources/bundled/bundled.go | 3 +-
.../resources/bundled/bundled_test.go | 34 +-
packages/cli/tools/sync-resources/main.go | 47 ++-
.../cli/tools/sync-resources/main_test.go | 29 +-
.../cli/tools/verify-cli-references/main.go | 3 +-
packages/templates/astro-site/package.json | 2 +-
.../electron-app/{README.md.hbs => README.md} | 11 +-
.../{package.json.hbs => package.json} | 4 +-
.../apps/electron/script/sandbox-profile.mjs | 5 +-
.../src/types/{events.ts.hbs => events.ts} | 4 +-
.../src/utils/{index.ts.hbs => index.ts} | 5 +-
.../ui/{package.json.hbs => package.json} | 4 +-
.../hooks/{electron.ts.hbs => electron.ts} | 2 +-
.../HomePage/{index.tsx.hbs => index.tsx} | 4 +-
.../electron-app/apps/ui/src/renderer.d.ts | 2 +
.../apps/ui/src/renderer.d.ts.hbs | 2 -
packages/templates/electron-app/package.json | 33 ++
.../templates/electron-app/package.json.hbs | 33 --
.../{package.json.hbs => package.json} | 2 +-
.../electron-app/pnpm-workspace.yaml | 7 +
packages/templates/electron-app/template.json | 26 ++
packages/templates/expo-mobile/package.json | 2 +-
.../go-api/{README.md.hbs => README.md} | 2 +-
.../cmd/server/{main.go.hbs => main.go} | 6 +-
packages/templates/go-api/go.mod | 7 +-
packages/templates/go-api/go.mod.hbs | 93 -----
packages/templates/go-api/go.sum.hbs | 277 --------------
packages/templates/go-api/go.work | 3 +
.../internal/app/{app.go.hbs => app.go} | 14 +-
.../{app_handler.go.hbs => app_handler.go} | 4 +-
.../{auth_handler.go.hbs => auth_handler.go} | 4 +-
...ealth_handler.go.hbs => health_handler.go} | 2 +-
.../{user_handler.go.hbs => user_handler.go} | 4 +-
.../http/middleware/{auth.go.hbs => auth.go} | 4 +-
.../http/middleware/{cors.go.hbs => cors.go} | 2 +-
.../{recovery.go.hbs => recovery.go} | 2 +-
.../http/{router.go.hbs => router.go} | 8 +-
.../postgres/{postgres.go.hbs => postgres.go} | 2 +-
...r_repository.go.hbs => user_repository.go} | 2 +-
.../{auth_service.go.hbs => auth_service.go} | 6 +-
.../{user_service.go.hbs => user_service.go} | 4 +-
packages/templates/go-api/template.json | 15 +
.../go-lib/{README.md.hbs => README.md} | 6 +-
packages/templates/go-lib/go.mod | 7 +-
packages/templates/go-lib/go.mod.hbs | 3 -
packages/templates/go-lib/go.work | 3 +
.../greeter/{greeter.go.hbs => greeter.go} | 2 +-
.../{greeter_test.go.hbs => greeter_test.go} | 0
packages/templates/go-lib/template.json | 22 ++
packages/templates/nestjs-api/package.json | 2 +-
packages/templates/nextjs-app/package.json | 2 +-
packages/templates/react-spa/package.json | 2 +-
.../templates/starlight-docs/package.json | 2 +-
packages/templates/ts-library/package.json | 2 +-
79 files changed, 2068 insertions(+), 839 deletions(-)
create mode 100644 docs/plans/2026-09-29-runnable-project-templates.md
create mode 100644 packages/cli/internal/core/template/node.go
create mode 100644 packages/cli/internal/core/template/node_test.go
delete mode 100644 packages/cli/internal/core/template/now.go
create mode 100644 packages/cli/internal/core/template/render_test.go
create mode 100644 packages/cli/internal/core/template/spec.go
create mode 100644 packages/cli/internal/core/template/transform.go
create mode 100644 packages/cli/internal/modules/creation/runnable_templates_test.go
create mode 100644 packages/cli/internal/platform/jsonedit/fields.go
create mode 100644 packages/cli/internal/platform/jsonedit/sort.go
create mode 100644 packages/cli/internal/platform/jsonedit/sort_test.go
create mode 100644 packages/cli/internal/platform/jsonedit/strings.go
create mode 100644 packages/cli/internal/platform/jsonedit/strings_test.go
create mode 100644 packages/cli/internal/platform/templatefiles/policy.go
rename packages/templates/electron-app/{README.md.hbs => README.md} (94%)
rename packages/templates/electron-app/apps/electron/{package.json.hbs => package.json} (91%)
rename packages/templates/electron-app/apps/electron/src/types/{events.ts.hbs => events.ts} (78%)
rename packages/templates/electron-app/apps/electron/src/utils/{index.ts.hbs => index.ts} (80%)
rename packages/templates/electron-app/apps/ui/{package.json.hbs => package.json} (90%)
rename packages/templates/electron-app/apps/ui/src/hooks/{electron.ts.hbs => electron.ts} (74%)
rename packages/templates/electron-app/apps/ui/src/nodes/HomePage/{index.tsx.hbs => index.tsx} (96%)
create mode 100644 packages/templates/electron-app/apps/ui/src/renderer.d.ts
delete mode 100644 packages/templates/electron-app/apps/ui/src/renderer.d.ts.hbs
create mode 100644 packages/templates/electron-app/package.json
delete mode 100644 packages/templates/electron-app/package.json.hbs
rename packages/templates/electron-app/packages/preload/{package.json.hbs => package.json} (92%)
create mode 100644 packages/templates/electron-app/pnpm-workspace.yaml
create mode 100644 packages/templates/electron-app/template.json
rename packages/templates/go-api/{README.md.hbs => README.md} (98%)
rename packages/templates/go-api/cmd/server/{main.go.hbs => main.go} (84%)
delete mode 100644 packages/templates/go-api/go.mod.hbs
delete mode 100644 packages/templates/go-api/go.sum.hbs
create mode 100644 packages/templates/go-api/go.work
rename packages/templates/go-api/internal/app/{app.go.hbs => app.go} (67%)
rename packages/templates/go-api/internal/http/handlers/{app_handler.go.hbs => app_handler.go} (76%)
rename packages/templates/go-api/internal/http/handlers/{auth_handler.go.hbs => auth_handler.go} (87%)
rename packages/templates/go-api/internal/http/handlers/{health_handler.go.hbs => health_handler.go} (90%)
rename packages/templates/go-api/internal/http/handlers/{user_handler.go.hbs => user_handler.go} (92%)
rename packages/templates/go-api/internal/http/middleware/{auth.go.hbs => auth.go} (85%)
rename packages/templates/go-api/internal/http/middleware/{cors.go.hbs => cors.go} (93%)
rename packages/templates/go-api/internal/http/middleware/{recovery.go.hbs => recovery.go} (86%)
rename packages/templates/go-api/internal/http/{router.go.hbs => router.go} (82%)
rename packages/templates/go-api/internal/platform/postgres/{postgres.go.hbs => postgres.go} (97%)
rename packages/templates/go-api/internal/repository/{user_repository.go.hbs => user_repository.go} (95%)
rename packages/templates/go-api/internal/service/{auth_service.go.hbs => auth_service.go} (86%)
rename packages/templates/go-api/internal/service/{user_service.go.hbs => user_service.go} (93%)
create mode 100644 packages/templates/go-api/template.json
rename packages/templates/go-lib/{README.md.hbs => README.md} (85%)
delete mode 100644 packages/templates/go-lib/go.mod.hbs
create mode 100644 packages/templates/go-lib/go.work
rename packages/templates/go-lib/pkg/greeter/{greeter.go.hbs => greeter.go} (88%)
rename packages/templates/go-lib/pkg/greeter/{greeter_test.go.hbs => greeter_test.go} (100%)
create mode 100644 packages/templates/go-lib/template.json
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 703167e..bc2eb9a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -135,6 +135,9 @@ jobs:
# Calls the same test subtask as local `mise run check`.
run: mise run check:test
+ - name: Build template sources and generated projects
+ run: mise run check:templates
+
test-windows:
runs-on: windows-latest
steps:
diff --git a/.gitignore b/.gitignore
index 2999a51..facc4a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,7 @@ bin-go/
# mise task artifacts
.cache/mise-task-artifacts/
+
+# Local template development; generated projects resolve their own workspace lock.
+/packages/templates/**/pnpm-lock.yaml
+/packages/templates/**/go.work.sum
diff --git a/apps/docs/content/docs/en/templates.md b/apps/docs/content/docs/en/templates.md
index dfe4976..f14ebea 100644
--- a/apps/docs/content/docs/en/templates.md
+++ b/apps/docs/content/docs/en/templates.md
@@ -117,3 +117,83 @@ must be adjusted at the root. For concurrent desktop development, set a differen
These rules apply to newly generated projects. Existing Electron projects are not
automatically rewritten or migrated.
+
+
+## Develop directly in a template directory
+
+Bundled templates contain ordinary source files that you can run and debug in
+`packages/templates/`. Go templates have a local `go.work` to isolate them
+from the repository workspace. Electron has a `pnpm-workspace.yaml` for template
+development. These development files are excluded from generated projects,
+which use the destination One workspace's configuration.
+
+For example, inside the One CLI source repository:
+
+```sh
+cd packages/templates/go-api
+go test ./...
+go run ./cmd/server
+```
+
+The Go API defaults to in-memory SQLite. Configure environment variables as
+described by the template when using PostgreSQL or other runtime settings.
+
+```sh
+cd packages/templates/electron-app
+pnpm install
+pnpm run dev
+```
+
+Electron builds preload first, then starts the main process and Vite UI. The
+host still needs Electron's graphical environment and system sandbox support.
+Use `pnpm run build` for a build without launching the application.
+
+For a single-package Node template such as React, isolate the parent workspace:
+
+```sh
+cd packages/templates/react-spa
+pnpm --ignore-workspace install
+pnpm --ignore-workspace run dev
+```
+
+Template development uses the pnpm version in its `package.json`. Generated
+projects inherit the destination workspace's version. Local dependencies,
+lockfiles and build artifacts are excluded from the CLI bundle.
+
+## Configure template generation
+
+Files are copied verbatim by default. Only starters needing parameterization
+include a `template.json` file, with these supported settings:
+
+| Setting | Purpose |
+| --- | --- |
+| `schemaVersion: 1` | Declare the descriptor version |
+| `go.modulePrefix` | Set the generated module path and rewrite its Go imports |
+| `node.scope`, `node.sourceFiles` | Rename internal Node packages, dependency keys, scripts and the scope in listed source files |
+| `text` | Replace example text in explicitly listed files |
+| `exclude` | Exclude files or directories used only for template development |
+
+Each `text` rule contains `files`, `from` and `value`. The supported values are
+`projectName` and `projectNameKebabCase`. Optional `minMatches` defaults to 1;
+every listed file must meet that count. Paths are exact, template-relative paths.
+No scripts or expressions are executed. Unknown fields, missing files,
+insufficient matches and overlapping replacements fail before destination writes.
+
+Go templates maintain one normal `go.mod` and a `go.sum` when needed. Bundling
+temporarily renames `go.mod` to `_go.mod` to avoid Go's nested-module embedding
+restriction; generation restores the filename. Do not edit the generated
+resources in `packages/cli/internal/resources/bundled/` manually.
+
+After editing, run these commands from the repository root:
+
+```sh
+mise run sync-bundled
+mise run check
+mise run check:templates
+```
+
+`check:templates` installs the Electron template dependencies, builds the Go and
+Electron sources, checks generated formatting for every Node template, and
+builds two differently named Electron projects plus two Go projects in a
+temporary workspace. It requires network access and the corresponding
+toolchains, and does not change the developer's global language preference.
diff --git a/apps/docs/content/docs/zh/templates.md b/apps/docs/content/docs/zh/templates.md
index 296fe28..cf9ba01 100644
--- a/apps/docs/content/docs/zh/templates.md
+++ b/apps/docs/content/docs/zh/templates.md
@@ -112,3 +112,74 @@ Node 模板不复制预生成的锁文件。`one dev` 会按需生成或更新
多个桌面应用同时开发时,通过各项目环境中的 `ELECTRON_RENDERER_PORT` 配置不同端口。
这些规则适用于新生成的项目,已有 Electron 项目不会自动改写目录或依赖配置。
+
+
+## 在模板目录直接开发
+
+内置模板使用正常源码文件,可以在 `packages/templates/` 内直接运行和调试。
+Go 使用本地 `go.work` 隔离仓库根模块;Electron 使用仅用于模板开发的
+`pnpm-workspace.yaml`。这些开发配置不会复制到生成项目,生成后的项目仍由
+One 工作区根目录管理。
+
+在 One CLI 源码仓库中,例如:
+
+```sh
+cd packages/templates/go-api
+go test ./...
+go run ./cmd/server
+```
+
+Go API 默认使用内存 SQLite。需要 PostgreSQL 或其他运行配置时,按模板说明设置环境变量。
+
+```sh
+cd packages/templates/electron-app
+pnpm install
+pnpm run dev
+```
+
+Electron 会先构建 preload,再启动主进程和 Vite UI。运行环境仍需要满足 Electron
+的图形界面和系统沙箱要求。仅构建可使用 `pnpm run build`。
+
+单包 Node 模板(例如 React)开发时要隔离父工作区:
+
+```sh
+cd packages/templates/react-spa
+pnpm --ignore-workspace install
+pnpm --ignore-workspace run dev
+```
+
+模板开发使用其 `package.json` 声明的 pnpm 版本;生成后沿用目标工作区的版本。
+本地依赖、锁文件、构建产物不会打包进 CLI。
+
+## 修改模板的生成规则
+
+默认按原始字节复制文件。只有需要参数化的模板才包含 `template.json`,目前支持:
+
+| 配置 | 用途 |
+| --- | --- |
+| `schemaVersion: 1` | 声明描述文件版本 |
+| `go.modulePrefix` | 生成 module 路径,并同步改写对应 Go import |
+| `node.scope`、`node.sourceFiles` | 修改内部 Node 包名、依赖键、scripts 和指定源码中的 scope |
+| `text` | 在明确列出的文件中替换示例文字 |
+| `exclude` | 排除模板开发专用的文件或目录 |
+
+`text` 的每条规则使用 `files`、`from`、`value`;`value` 仅支持
+`projectName` 和 `projectNameKebabCase`。可选 `minMatches` 默认为 1,
+每个指定文件都必须达到命中次数。路径是模板内的精确相对路径,不执行脚本或表达式。
+未知字段、缺失文件、替换不足或重叠会在写入目标目录前报错。
+
+Go 模板只维护一份正常的 `go.mod` 和必要的 `go.sum`。资源打包层临时把
+`go.mod` 改名为 `_go.mod`,避开 Go 嵌入的子模块限制,生成时自动还原。
+不要手动编辑 `packages/cli/internal/resources/bundled/` 中的生成资源。
+
+修改后从仓库根目录运行:
+
+```sh
+mise run sync-bundled
+mise run check
+mise run check:templates
+```
+
+`check:templates` 会安装 Electron 模板依赖,构建 Go/Electron 源码,检查全部
+Node 模板的生成格式,并在临时工作区构建两个不同名称的 Electron 项目和两个 Go 项目。
+该检查需要网络及对应工具链;它不会修改开发者的全局语言偏好。
diff --git a/docs/plans/2026-09-29-runnable-project-templates.md b/docs/plans/2026-09-29-runnable-project-templates.md
new file mode 100644
index 0000000..7f7b46e
--- /dev/null
+++ b/docs/plans/2026-09-29-runnable-project-templates.md
@@ -0,0 +1,303 @@
+# 可运行项目模板迁移规划
+
+日期:2026-09-29。状态:已实施;文末记录实现差异、验证结果和边界。
+
+推荐方向:模板源码保存为正常、可独立开发的示例项目;生成时复制文件,再执行少量有明确边界的修改。One CLI 继续用 Go 完成离线生成,复用现有工作区配置、格式保留和文件写入机制。
+
+## 1. 调研结论与方案选择
+
+项目脚手架已有成熟实践,但没有统一的“最佳模板引擎”。需要分别考虑文件获取、项目参数化、功能组合、生成后的持续更新。
+
+| 参考项目 | 已核实的做法 | 对 One CLI 的意义 |
+| --- | --- | --- |
+| [create-vite](https://github.com/vitejs/vite/blob/main/packages/create-vite/src/index.ts) | 复制普通模板目录,修改 package.json 的 name、HTML 标题,按选项执行配置修改 | 适合现有内置模板,作为总体参考 |
+| [create-vue 文件复制](https://github.com/vuejs/create-vue/blob/main/utils/renderTemplate.ts)、[生成入口](https://github.com/vuejs/create-vue/blob/main/index.ts) | 组合目录、合并 package.json,部分配置仍使用 EJS | 证明“组合文件 + 少量渲染”也是合理设计;未来有真实功能组合需求再引入目录叠加 |
+| [gonew 源码](https://github.com/golang/tools/blob/master/cmd/gonew/main.go)、[官方介绍](https://go.dev/blog/gonew) | 普通 Go module 作为模板;复制时解析并修改 module 和 import | Go 模板直接参考其修改边界;工具仍标注实验性,不把它当作稳定依赖或运行时前置条件 |
+| [Giget](https://github.com/unjs/giget) | 下载 Git 仓库或归档,提供来源、子目录和缓存等能力 | 解决模板获取;当前 One CLI 的内置模板迁移不需要引入下载工具 |
+| [Yeoman](https://yeoman.io/authoring/file-system) | 支持普通文件复制、EJS copyTpl、内存文件系统和冲突处理 | 可参考先准备内容再写入的组织方式;整套 Node generator 运行时超出当前需要 |
+| [Copier 模板](https://copier.readthedocs.io/en/stable/creating/)、[项目更新](https://copier.readthedocs.io/en/stable/updating/) | Jinja 参数化;保留生成答案并结合旧、新模板重建及差异应用来更新项目 | 如果产品目标变为持续升级已有项目,应单独评估 Copier 路线;删除 HBS 本身不会提供升级能力 |
+
+推荐采用 create-vite 的总体方式和 gonew 的 Go 文件修改方式,用现有标准库、golang.org/x/mod 和仓库工具实现少量适配代码。以上工具覆盖的问题与 One CLI 不完全相同,没有理由为了删除 HBS 再引入 Node/Python 生成运行时。
+
+继续使用 HBS 并非技术错误,但当前内置模板没有使用条件、循环或 helper,投入通用模板引擎带来的维护成本已经大于收益。换成 EJS、Jinja 或 Go text/template,仍然需要维护不能直接编译的模板源码。
+
+## 2. 仓库现状与真实迁移规模
+
+调研时工作区没有未提交变更。注册表包含 13 个模板:8 个 Node、2 个 Go、3 个 empty 模板。
+
+| 模板 | HBS 文件数 | 实际变量 | 迁移重点 |
+| --- | ---: | --- | --- |
+| go-api | 17 | projectNameKebabCase | go.mod、import、README;go.sum.hbs 没有变量 |
+| go-lib | 4 | projectNameKebabCase | go.mod、注释、README;greeter_test.go.hbs 没有变量 |
+| electron-app | 10 | projectNameKebabCase、projectName | 根包及 3 个成员包、依赖键、scripts、源码引用、README |
+| 其他 10 个模板 | 0 | 无 HBS 插值 | 行为回归验证 |
+
+合计 31 个 HBS 文件,只用了两个变量,没有 HBS 条件、循环。没有 template.json 文件,也没有实际使用文件名变量;Expo 的 __tests__、__snapshots__ 是普通目录名,必须保持原样。
+
+当前处理链:
+
+```text
+packages/templates 源码
+ → tools/sync-resources 同步到 bundled/_templates
+ → go:embed
+ → template.Render 写入项目目录
+ → creation 配置 package.json、工作区成员和 manifest
+ → FilePlan.Apply
+ → syncProject 生成开发相关文件
+```
+
+需要以实现为准的边界:
+
+- `internal/core/template/render.go` 仅对 .hbs 调用 Raymond,其余文件原样复制;go.mod/go.sum 在渲染阶段被排除。
+- `tools/sync-resources/main.go` 在打包时排除 go.mod。不能简单删除这个条件,详见第 5 节。
+- `internal/modules/creation/project.go` 只接受 `local:` 模板。注册表可以来自网络,不代表模板内容已支持远程下载。
+- `render.go` 中关于 template.json 变量扩展的注释没有对应的实际加载逻辑;新描述文件应明确作为新能力实现。
+- `configureNodePackage` 已经负责根 package.json 的项目名和 packageManager;`json_fields.go` 保留未修改字段的格式和顺序,应继续复用。
+- 模板目前直接写入目标目录。创建失败时,仅对本次新建的目录执行清理;根配置通过 FilePlan 管理;syncProject 在提交后运行。当前流程不是整个创建操作的完整事务。
+- `NodeProjectPackageDirs` 虽然支持读取 FilePlan overlay,成员发现仍使用磁盘 Glob,不能只把文件放入内存就假设所有发现逻辑可用。
+
+## 3. 本次范围与完成后的行为
+
+本次覆盖内置模板源码、嵌入打包、参数化、生成回归和模板维护文档。
+
+完成后应满足:
+
+1. 开发者直接编辑 .go、.ts、.tsx、package.json、go.mod,编辑器及语言工具链能正常工作。
+2. 每个 Go 模板只有一份 go.mod;go-api 只有一份 go.sum,go-lib 不人为生成空 go.sum。
+3. 创建命令、模板 ID/code、preset 编码、项目目录布局和已有命名规则保持兼容。
+4. `My_API` 等合法输入仍按现有规则生成项目路径、显示名称及 kebab-case 包名;不在迁移中重新定义名称规范。
+5. Go 生成结果仍默认使用 `github.com/example/`;Electron 仍使用 `@/`,允许多个桌面项目共存。
+6. 新增模板相关错误、校验提示、帮助及公开文档同步提供 zh-CN/en-US,保持具体路径、原因和错误链。
+7. 创建操作不增加 Node、Python、gonew 或外部格式化器的前置依赖;依赖安装和真实构建仍按现有流程执行。
+
+远程模板下载、任意自定义问题、脚本 hook、功能选择矩阵、已有项目升级、项目重命名和新的 module-path CLI 参数作为后续独立需求。第一版不承诺这些能力。
+
+## 4. 目标架构与职责
+
+```mermaid
+flowchart LR
+ A[正常的示例项目] --> B[资源打包与文件名映射]
+ B --> C[内置文件系统]
+ C --> D[复制并校验模板描述]
+ D --> E[Go / Node / 文本定向修改]
+ E --> F[现有 creation 工作区集成]
+ F --> G[生成后的真实项目检查]
+```
+
+默认复制文件,只有声明了参数化需求的模板才添加 `template.json`。普通 Node 模板继续使用现有 `configureNodePackage`。empty 模板继续保持轻量。
+
+第一版描述文件仅承载内置模板确实需要的数据,使用带版本的 JSON 和严格 Go 类型。它不是脚本语言,不提供表达式、条件、循环或任意命令。版本固定为 schemaVersion=1,未知版本和未知字段报错;文件不复制到用户项目中。
+
+拟议的 Go 模板描述如下,属于待实现接口,不是现有格式:
+
+```json
+{
+ "schemaVersion": 1,
+ "go": {
+ "modulePrefix": "github.com/example/"
+ },
+ "text": [
+ {
+ "files": ["README.md"],
+ "from": "one-template-go-api",
+ "value": "projectNameKebabCase",
+ "minMatches": 1
+ }
+ ]
+}
+```
+
+源码 go.mod 使用 `module github.com/example/one-template-go-api`,import 使用同一前缀。旧 module 从 go.mod 读取,目标值由 modulePrefix 与现有规范化名称生成。README 的示例名称单独替换。
+
+第一版只提供以下配置项:
+
+| 字段 | 语义 |
+| --- | --- |
+| schemaVersion | 描述文件协议版本 |
+| go.modulePrefix | 生成 Go module 的固定前缀,旧 module 从源码读取 |
+| node.scope | Electron 示例内部包的完整 scope,如 @one-template-electron |
+| node.sourceFiles | 需要替换该 scope 引用的精确源码路径列表 |
+| text | 指定文件中的字面量替换;value 只允许 projectName / projectNameKebabCase |
+| exclude | 不交付给生成项目的开发文件或目录,精确相对路径,无 glob |
+
+`node.scope` 对应的新 scope 固定为规范化项目名。源 package.json 的 workspaces 继续作为成员关系的唯一依据,不在描述文件重复维护成员清单。
+
+文本规则按原始内容定位后一次性应用,检测区间重叠,避免一个规则改出的内容再次被另一个规则替换。每条规则每个指定文件需满足 minMatches,默认 1;源字符串为空、文件不存在、命中不足、规则重叠均报错。只处理指定的 UTF-8 文本,不做全目录字符串替换。新增文案仍需正确处理所在文档上下文。
+
+不新增通用 JSONPath 或任意 JSON patch 配置:Node 包名、依赖和脚本处理由受限适配器负责。先复用已有 JSON 编辑器,必要时下沉为内部共享包,避免 template 反向依赖 creation。
+
+## 5. Go 模板:源码、嵌入与参数化
+
+### 5.1 恢复普通 Go 模块
+
+- 将 21 个 Go HBS 文件恢复成正常文件名,源码使用合法且唯一的示例 module。
+- 合并 go.mod 与 go.mod.hbs;go.sum 以当前真正交付的 go.sum.hbs 为迁移基准,在完整源码上校验后保留一份。
+- 在模板源码目录以隔离的 Go workspace 配置运行检查,例如进程环境 GOWORK=off;不修改开发者全局 go env。
+- 保留既有 Go 版本和依赖版本。此次迁移不顺带升级依赖。
+- go-lib 的库注释以精确文本规则处理;greeter 包名保持现有语义,不自动随项目名改名。
+
+### 5.2 必须处理 go:embed 的模块边界
+
+Go 官方明确规定:嵌入模式不能匹配包含 go.mod 的独立模块目录。`all:` 允许隐藏文件,但不会取消模块边界。因此普通 go.mod 不能原名放入 bundled/_templates。[Go embed 文档](https://pkg.go.dev/embed)
+
+推荐在打包层做一个明确映射:
+
+```text
+模板源码 内部打包目录 用户项目
+go.mod → _go.mod → go.mod
+go.sum → go.sum → go.sum
+main.go → main.go → main.go
+```
+
+`_go.mod` 只存在于自动生成的打包目录。源码仍然是正常 Go 项目。同步工具预先拒绝源码中与保留文件名冲突的文件;渲染器只还原这一个明确映射,不把所有下划线文件都改成隐藏文件。
+
+同步资源测试及 bundled 一致性测试需要通过映射比较逻辑路径与内容,既验证包含完整 Go 资源,也验证资源树内没有裸 go.mod。必须用真正的 CLI 编译和嵌入文件读取验收,单测临时目录复制不足以证明可用。
+
+### 5.3 Go 修改边界
+
+- 使用已有 `golang.org/x/mod/modfile` 读取并修改 module 声明,保留依赖集合与版本。
+- 使用 `go/parser` 的 import 解析能力定位导入字符串,仅修改等于旧 module 或以 `旧 module + /` 开头的 import。
+- 使用源码位置修改字节范围,保留别名、注释、build tags 和无关代码;避免遍历 AST 后重印整个文件产生无关 diff。
+- 解析覆盖全部 .go 文件,包括当前平台不参与编译的文件;嵌套模块第一版不支持,遇到时明确报错。
+- 不修改第三方路径、普通字符串、注释中的近似前缀。README 和说明性注释按 text 规则修改。
+- 复用模块路径校验。创建时不执行 go mod tidy;源码及生成项目的依赖完整性由 CI 验证。
+
+该边界参考 [gonew 实现](https://github.com/golang/tools/blob/master/cmd/gonew/main.go),使用的是已存在的 [modfile](https://pkg.go.dev/golang.org/x/mod/modfile) 和 [go/parser](https://pkg.go.dev/go/parser) API。
+
+## 6. Electron 与普通 Node 模板
+
+Electron 源码使用固定合法的内部包名,例如 `@one-template-electron/preload`、`@one-template-electron/electron`、`@one-template-electron/ui`;根包名使用正常示例名称。全部 .hbs 恢复正常扩展名。
+
+生成步骤:
+
+1. 读取根 package.json 及声明的成员包,建立“源包名 → 目标包名”映射,校验重复包名和目标冲突。
+2. 修改各成员的 name 以及 dependencies/devDependencies/peerDependencies/optionalDependencies 中对应的内部包键,保持 workspace:* 等依赖值。
+3. 在 scripts 字符串中,替换精确内部包引用,覆盖 pnpm -F 等现有用法;保留未知 shell 片段及 JSON 格式。匹配完整包名边界,避免修改名称相似的外部包。
+4. 在 node.sourceFiles 中替换明确的旧 scope 前缀。当前文件都是 import/export、类型引用或 require.resolve 中的字符串及相关注释,第一版不引入 JS/TS AST 运行时。
+5. README 的标题和命令使用 projectName,包 scope 使用 projectNameKebabCase,保持当前两种名称的区别。
+6. 继续让现有 creation 流程配置根包 name、包管理器版本、根工作区成员、锁文件策略及安装脚本策略。
+
+JSON 修改必须覆盖依赖对象的键;只改字符串值会遗漏最重要的 workspace 依赖。现有 JSON 编辑器仅支持直接字段,扩展嵌套对象及键重命名时必须保留原有格式回归测试,禁止回退为 map 整体序列化。
+
+模板维护需要一份仅用于开发的 pnpm-workspace.yaml,列出这三个成员,使 Electron 源码可以独立安装与运行。把它加入此模板的 exclude;生成到 One 工作区时仍由根工作区统一管理,避免产生嵌套安装边界。开发版本与仓库固定工具链需核对,不能顺带改变用户工作区的包管理器版本。
+
+其他单包 Node 模板没有 HBS,无需添加描述文件。直接维护时需要隔离父工作区设置;实施时以仓库固定 pnpm 版本的帮助为准确认 `--ignore-workspace` 使用方式。该选项用于独立安装的语义可参考 [pnpm 官方说明](https://github.com/pnpm/pnpm.io/blob/main/docs/cli/install.md)。CI 也应在仓库外临时目录验证源码,避免误用仓库根依赖。
+
+## 7. 复制规则、失败处理与兼容性
+
+### 7.1 文件交付规则
+
+- 默认按原始字节复制,图片、字体、图标等二进制不进入文本处理器。
+- 打包和运行时保持一致的基础排除规则,继续排除 .git、node_modules、已有 agent 指令与 Node 锁文件;补齐开发产生的构建产物、缓存排除,避免模板变得可运行后把 node_modules/dist 带入 CLI。
+- 排除规则需按模板路径和真实产物目录确定,不用宽泛后缀规则删除业务资源。
+- 描述文件保留在嵌入资源中供生成器读取,不写到用户项目。
+- 保持 .editorconfig、.env.example 等文件交付行为;__tests__、__snapshots__ 无条件按原名处理。
+- 生成文件权限至少保持当前 .sh 行为;额外可执行文件的需求出现后再扩展描述,不把嵌入 FS 的权限当作原始权限。
+- 模板输出路径必须留在目标目录;第一版内置模板禁止符号链接,防止打包阶段读出模板边界。
+
+### 7.2 写入流程
+
+本次新增的描述读取、文件复制及参数化先在内存文件集合中完成。所有规则验证通过后,才向目标目录写入;这使语法错误、缺失文件、命中不足等模板问题不会留下半个项目。
+
+第一版保持后续 creation 的磁盘发现流程,复用现有 WorkspaceLock、SafeWritePath 和 FilePlan。模板文件发布也用 FilePlan,以便写入失败时恢复本次修改,并记录实际创建的空目录用于清理。
+
+不在本次把整个 creation 改造成统一事务:需明确区分“模板准备失败”“模板文件写入失败”“后续工作区集成失败”和“提交后的 syncProject 失败”。既有新建目录清理与根配置回滚行为不能退化。已有空目录情况下的跨阶段全回滚,以及 syncProject 的整体事务化,作为单独后续改进,不能在发布说明中宣称本次已解决。
+
+### 7.3 HBS 退出方式
+
+本次迁移只影响后续生成,已有用户项目不需要执行迁移。内置资源随 CLI 一起发布,当前代码也不加载远程 HBS 模板,因此不建议长期保留双引擎。
+
+实施中可以按描述文件选择新流程、无描述时暂走旧流程,以支持分批验证。全部 31 个文件完成迁移并通过回归后,在同一交付周期移除 Raymond、HBS 分支及未使用的路径插值逻辑。
+
+删除路径插值前以调用点、测试和公开文档完成核对。若发现实际外部兼容承诺,按明确版本保留旧格式支持并记录删除条件;不要仅凭过时注释构建永久兼容层。错误码、registry JSON 字段、模板 ID/code 和 preset 编码保持稳定。
+
+## 8. 分阶段实施与审查切分
+
+| 阶段 | 改动及产物 | 通过条件 |
+| --- | --- | --- |
+| P0:固定基线 | 用当前生成器为 13 个模板记录路径清单、关键配置及命名结果;补足有意义的生成行为测试 | 能区分参数化变化、格式变化和不应出现的业务代码变化 |
+| P1:描述、复制和打包 | 实现受限描述解析、逻辑文件集合、Go 打包映射、复制排除及错误信息;用 go-lib 先走通 | 普通源码能测试;实际编译后的 CLI 能生成正确 go.mod;二进制资源原样交付 |
+| P2:Go 模板迁移 | 迁移 go-lib/go-api 共 21 个文件,合并 module/checksum 文件,加入 module/import 定向修改 | 源码及生成结果均可独立测试、构建;校验和与版本正确,第三方 import 未变 |
+| P3:Electron 迁移 | 迁移 10 个文件、内部包映射和开发用工作区配置,保留 JSON 格式 | 源码可独立构建;同一 One 工作区两个 Electron 项目能共存并正确解析 preload |
+| P4:收尾与发布门禁 | 删除 HBS/Raymond、过时注释及逻辑;同步资源测试、双语文档、CI 检查 | 13 个模板回归通过;模板源码无 HBS;mise run check 通过;最终产物完成真实 CLI 冒烟 |
+
+这些阶段是建议的审查边界,不要求为每阶段引入新的公共命令。每阶段保留可验证结果,Go 打包变更与对应渲染还原必须同批交付。描述与渲染器协议在 P1 固定,后续只添加模板数据。
+
+主要涉及的代码位置:
+
+- `packages/cli/internal/core/template/`:描述、复制、Go/Node 参数化、变量收敛及测试。
+- `packages/cli/internal/modules/creation/`:维持工作区集成边界,复用或下沉 JSON 编辑能力,扩充生成集成测试。
+- `packages/cli/tools/sync-resources/` 和 `internal/resources/bundled/`:打包映射、排除规则、嵌入资源一致性。
+- `packages/templates/go-api/`、`go-lib/`、`electron-app/`:正常项目源码与少量描述数据。
+- `packages/cli/tools/verify-cli-references/`:移除 HBS README 扫描分支,保留普通 README 验证。
+- CLI 的两份 locale 字典、相关中英文模板文档、`mise.toml`/Taskfile/CI 中必要的模板验证任务。
+- `packages/cli/go.mod`、`go.sum`:全部迁移完成后清理 Raymond。
+
+预计工作量为一名熟悉仓库的开发者约 4–6 个工作日,含回归和审查修改;这是规划估算。Electron 原生依赖、跨平台构建和网络下载耗时可能增加日历时间。
+
+## 9. 验证矩阵与验收标准
+
+### 9.1 快速且确定的检查
+
+- 13 个模板都通过实际创建服务生成;覆盖普通名、连字符、下划线、大小写转换,以及两个输入归一化到相同包名的冲突。
+- Go 精确 module/import 修改:同前缀第三方模块、别名、注释、构建标签、平台专用文件、go.sum 字节保留。
+- Electron:4 份 package.json 的名称、依赖键、scripts、5 个源码文件的引用,以及 README;旧 scope 不残留,外部包不被改名。
+- 普通文件不变化:用路径集合和二进制哈希证明;无 .hbs、模板描述或内部打包名泄漏。
+- 描述错误、目标冲突、模板修改失败及写入失败的可预测行为;校验相关阶段没有不应发生的目标文件和根配置修改。
+- JSON 保留缩进、字段顺序、紧凑数组、换行符及 shell 字符,继续覆盖重复键和无效 JSON。
+- 两种语言下的错误与帮助;切换后无旧语言残留;测试使用临时 HOME/config。
+
+### 9.2 源码和生成结果分别检查
+
+| 对象 | 源码检查 | 生成结果检查 |
+| --- | --- | --- |
+| go-api/go-lib | 在源码目录隔离父 go.work,运行格式、测试和构建 | 临时 One 工作区和脱离父工作区的独立模块各检查一次;go.mod/go.sum 不被意外修改 |
+| electron-app | 独立 pnpm 工作区安装、类型检查及各包构建 | 同一 One 根目录加入两个不同名称的桌面项目,检查工作区依赖、preload 路径和构建 |
+| 其余 7 个 Node 模板 | 现有格式、类型与构建任务按各模板实际 scripts 执行 | 检查 package name、packageManager、根锁文件归属及生成格式 |
+| 3 个 empty 模板 | 文件清单 | 目录与 manifest、工具链值和 preset 行为 |
+
+Electron 的安装与构建属于依赖 CI;GUI 启动及各 OS 打包使用已有支持的平台环境,不把某一平台的构建通过当作所有平台打包通过。Go API 的外部服务相关启动测试需要明确配置,基础编译测试不能声称验证了数据库连接。
+
+### 9.3 实际运行门禁
+
+- `mise run sync-bundled` 验证生成资源。所有基于 embed 的测试运行前先同步,避免旧资源造成假通过。
+- 运行 template、creation、sync-resources、bundled 相关测试和 preset/帮助快照检查。
+- 在 CI 中显式提供现有 `ONE_TEST_OXFMT_BINARY` 格式检查入口;仅设置可选跳过的测试不足以作为门禁。
+- 增加模板源码与生成结果的依赖检查任务,按项目固定工具链执行;对受影响模板及生成器改动触发,发布前跑全量。
+- 执行仓库要求的 `mise run check`。该命令的现有内容不能替代上面的真实模板安装与构建矩阵。
+- 通过编译出的 One CLI 各生成一个 Go 和 Electron 项目,验证打包到最终二进制的资源与测试所用源码一致。
+
+最终完成标准:31 个 HBS 文件全部退出模板源码;3 个复杂模板可以以普通项目方式维护;13 个模板的现有用户行为保持兼容;源码和生成结果均有实际检查证据;没有给创建操作增加新的脚本语言运行时。
+
+## 10. 后续演进的触发条件
+
+当出现真实的多功能组合需求时,再评估 create-vue 式目录叠加,并先定义文件覆盖、依赖合并与冲突规则。当前不引入泛化的生成步骤 DSL。
+
+当需要远程社区模板时,单独设计来源解析、固定版本、缓存和可信执行边界;资源获取和文件参数化保持独立。此时再评估采用成熟下载组件的收益。
+
+当需要持续升级已有项目时,单独设计模板版本、生成参数记录和合并策略,并与 Copier 做完整取舍。仅记录 templateID 不足以安全复现旧生成结果。
+
+当前最先值得落地的切片是 go-lib:用一个小型正常 Go module 验证“源码可开发 → 正确嵌入 → 正确改名 → 生成结果可测试”的完整链路,再推广到 Go API 和 Electron。
+
+
+## 11. 实施记录(2026-09-29)
+
+- 已将 31 个 HBS 文件迁移为普通源码;移除 Raymond、路径插值及未使用的时间变量。
+- 三个描述文件使用第 4 节的受限配置;新增双语错误、未知字段与重复 JSON 键验证、文本命中与重叠验证。
+- 资源打包映射 go.mod → _go.mod;同步排除开发依赖、构建产物、锁文件和本地 go.work;资源测试现在比较文件内容。
+- Go 源码有开发专用 go.work;Electron 有开发专用 pnpm-workspace.yaml;二者不交付生成项目。
+- Node JSON 编辑器下沉为共享包;修改包名与依赖键时保留格式,并对改名后的依赖对象排序,使不同项目名都符合格式规则。
+- 模板开发原先固定的 pnpm@12.3.4 在 registry 中不存在,因此八个 Node 模板的开发版本对齐仓库 pnpm@10.14.0。生成到已有工作区时仍保留根版本。
+- 为避免模板示例 scope 长度影响格式,将 Electron preload 引用提取为独立常量;相关文件按现有 Oxfmt 规则整理。
+- 新增 mise run check:templates 并接入 Linux CI;检查源码构建、所有 Node 生成格式、两个 Go 项目和同工作区内两个 Electron 项目。
+
+验证证据:
+
+- 最初完成参数化迁移后,13 个模板 × 两组名称的全部生成文件与迁移前逐字节一致;随后可见差异仅为开发 pnpm 版本、描述文件、开发配置及上述格式/源码常量整理。
+- mise run check 通过,包含 Go/E2E、文档检查及 Dashboard 95 个测试。
+- Go API / Go Library 直接从模板源码测试和构建通过;Electron 直接从模板目录安装、格式检查和完整构建通过。
+- React 模板使用 --ignore-workspace 直接安装与构建通过。Go API 模板实际启动后 /health 返回 HTTP 200(SQLite)。
+- 依赖构建检查通过:在配置了仓库 pnpm 版本的临时工作区生成两个 Go 项目,以及 Alpha_Desktop / zulu-desktop 两个 Electron 项目,全部测试/构建和格式检查通过。
+- 编译后的 CLI 在临时 HOME 中完成 go-api / electron-app 创建,module、scope 和开发文件排除正确。
+
+边界:本次没有改动工作区创建器原有的默认 pnpm@12.3.4,也没有改写用户现有工作区版本。真实依赖构建检查使用预先配置 pnpm@10.14.0 的工作区;全新工作区默认版本不可下载的问题仍需单独处理。未在本次验证 Windows/macOS 打包,也未把 Electron 构建通过视为 GUI 与系统沙箱启动验证。
diff --git a/mise.toml b/mise.toml
index 7614311..e7dc1d0 100644
--- a/mise.toml
+++ b/mise.toml
@@ -89,6 +89,12 @@ depends = ["test:go:plain", "test:dashboard"]
description = "Run the complete local and CI gate"
depends = ["check:static", "check:test"]
+[tasks."check:templates"]
+description = "Build runnable template sources and generated Go/Electron projects"
+depends = ["sync-bundled", "sync-web"]
+run = "go test -count=1 -timeout=20m -v ./packages/cli/internal/modules/creation -run ^TestRunnableTemplateSourcesAndProjects$"
+env = { ONE_TEST_TEMPLATE_BUILDS = "1" }
+
[tasks.ci]
depends = ["check"]
diff --git a/packages/cli/go.mod b/packages/cli/go.mod
index 68c4b1e..d4f8f76 100644
--- a/packages/cli/go.mod
+++ b/packages/cli/go.mod
@@ -6,7 +6,6 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.6
- github.com/aymerick/raymond v2.0.2+incompatible
github.com/charmbracelet/x/ansi v0.11.8
github.com/creack/pty v1.1.24
github.com/gofrs/flock v0.13.1
diff --git a/packages/cli/go.sum b/packages/cli/go.sum
index c59f318..4b6b951 100644
--- a/packages/cli/go.sum
+++ b/packages/cli/go.sum
@@ -48,8 +48,6 @@ github.com/aws/smithy-go v1.28.2 h1:myhcykQcatTul2B/zITjDk203G7t0awUAs1hVry5Bvg=
github.com/aws/smithy-go v1.28.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
-github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfKMjPOA3SbKLtnU0=
-github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
diff --git a/packages/cli/internal/core/template/node.go b/packages/cli/internal/core/template/node.go
new file mode 100644
index 0000000..c0de8d6
--- /dev/null
+++ b/packages/cli/internal/core/template/node.go
@@ -0,0 +1,192 @@
+package template
+
+import (
+ "bytes"
+ "encoding/json"
+ "path"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/jsonedit"
+)
+
+var scopeRE = regexp.MustCompile(`^@[a-z0-9][a-z0-9._-]*$`)
+var packageTokenRE = regexp.MustCompile(`@[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*`)
+
+// The files already exist in memory here; discover declared workspace members
+// from logical paths, without consulting the destination filesystem.
+func nodeMembers(files map[string][]byte) ([]string, error) {
+ raw, ok := files["package.json"]
+ if !ok {
+ return nil, i18n.Errorf("template.file_missing", "package.json")
+ }
+ var root struct{ Workspaces json.RawMessage }
+ if err := json.Unmarshal(raw, &root); err != nil {
+ return nil, err
+ }
+ var patterns []string
+ if err := json.Unmarshal(root.Workspaces, &patterns); err != nil {
+ var obj struct{ Packages []string }
+ if err := json.Unmarshal(root.Workspaces, &obj); err != nil {
+ return nil, i18n.Errorf("template.spec_invalid", "package.json: workspaces")
+ }
+ patterns = obj.Packages
+ }
+ members := []string{"package.json"}
+ for _, pattern := range patterns {
+ p := strings.TrimPrefix(pattern, "!")
+ if !fsPathPattern(p) {
+ return nil, i18n.Errorf("template.spec_path", pattern)
+ }
+ }
+ for _, name := range sortedFiles(files) {
+ if name == "package.json" || path.Base(name) != "package.json" {
+ continue
+ }
+ included, excluded := false, false
+ for _, pattern := range patterns {
+ negative := strings.HasPrefix(pattern, "!")
+ match, err := path.Match(strings.TrimPrefix(pattern, "!"), path.Dir(name))
+ if err != nil {
+ return nil, err
+ }
+ if match {
+ if negative {
+ excluded = true
+ } else {
+ included = true
+ }
+ }
+ }
+ if included && !excluded {
+ members = append(members, name)
+ }
+ }
+ if len(members) < 2 {
+ return nil, i18n.Errorf("template.spec_invalid", "package.json: workspaces")
+ }
+ // Explicit member paths must not silently disappear.
+ for _, pattern := range patterns {
+ if strings.HasPrefix(pattern, "!") || strings.ContainsAny(pattern, "*?[") {
+ continue
+ }
+ if _, ok := files[path.Join(pattern, "package.json")]; !ok {
+ return nil, i18n.Errorf("template.file_missing", path.Join(pattern, "package.json"))
+ }
+ }
+ return members, nil
+}
+
+func fsPathPattern(p string) bool {
+ if p == "" || strings.ContainsAny(p, `\:{}()`) || strings.Contains(p, "**") || strings.HasPrefix(p, "/") {
+ return false
+ }
+ for _, part := range strings.Split(p, "/") {
+ if part == "" || part == "." || part == ".." {
+ return false
+ }
+ }
+ _, err := path.Match(p, "")
+ return err == nil
+}
+
+func rewriteNode(files map[string][]byte, spec NodeSpec, vars Variables) error {
+ targetScope := "@" + vars["projectNameKebabCase"]
+ if !scopeRE.MatchString(targetScope) {
+ return i18n.Errorf("template.spec_invalid", "projectNameKebabCase")
+ }
+ members, err := nodeMembers(files)
+ if err != nil {
+ return err
+ }
+ names := map[string]string{}
+ for _, file := range members {
+ var pkg struct{ Name string }
+ if err := json.Unmarshal(files[file], &pkg); err != nil {
+ return i18n.Errorf("template.transform_failed", file, err)
+ }
+ if pkg.Name == "" {
+ return i18n.Errorf("template.spec_invalid", file+": name")
+ }
+ if _, ok := names[pkg.Name]; ok {
+ return i18n.Errorf("template.file_collision", pkg.Name)
+ }
+ target := vars["projectNameKebabCase"]
+ if file != "package.json" {
+ if !strings.HasPrefix(pkg.Name, spec.Scope+"/") || packageTokenRE.FindString(pkg.Name) != pkg.Name {
+ return i18n.Errorf("template.spec_invalid", file+": name")
+ }
+ target = targetScope + strings.TrimPrefix(pkg.Name, spec.Scope)
+ }
+ names[pkg.Name] = target
+ }
+ for _, file := range members {
+ raw, err := jsonedit.RewriteStrings(files[file], func(p []string, key bool, value string) (string, error) {
+ if !key && len(p) == 1 && p[0] == "name" {
+ return names[value], nil
+ }
+ if len(p) == 2 {
+ switch p[0] {
+ case "dependencies", "devDependencies", "peerDependencies", "optionalDependencies":
+ if key {
+ if target, ok := names[value]; ok {
+ return target, nil
+ }
+ }
+ case "scripts":
+ if !key {
+ return packageTokenRE.ReplaceAllStringFunc(value, func(token string) string {
+ if target, ok := names[token]; ok {
+ return target
+ }
+ return token
+ }), nil
+ }
+ }
+ }
+ return value, nil
+ })
+ if err != nil {
+ return i18n.Errorf("template.transform_failed", file, err)
+ }
+ // Keep renamed dependency keys in the order required by the formatter.
+ var pkg map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &pkg); err != nil {
+ return err
+ }
+ updates := map[string]json.RawMessage{}
+ for _, field := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} {
+ if value, ok := pkg[field]; ok {
+ sorted, err := jsonedit.SortKeys(value)
+ if err != nil {
+ return i18n.Errorf("template.transform_failed", file, err)
+ }
+ if !bytes.Equal(value, sorted) {
+ updates[field] = sorted
+ }
+ }
+ }
+ raw, err = jsonedit.UpdateFields(raw, updates)
+ if err != nil {
+ return err
+ }
+ files[file] = raw
+ }
+ for _, file := range spec.SourceFiles {
+ raw, ok := files[file]
+ if !ok {
+ return i18n.Errorf("template.file_missing", file)
+ }
+ if !utf8.Valid(raw) || bytes.IndexByte(raw, 0) >= 0 {
+ return i18n.Errorf("template.text_invalid", file)
+ }
+ from := spec.Scope + "/"
+ if !bytes.Contains(raw, []byte(from)) {
+ return i18n.Errorf("template.text_matches", file, from, 1, 0)
+ }
+ files[file] = bytes.ReplaceAll(raw, []byte(from), []byte(targetScope+"/"))
+ }
+ return nil
+}
diff --git a/packages/cli/internal/core/template/node_test.go b/packages/cli/internal/core/template/node_test.go
new file mode 100644
index 0000000..14862a5
--- /dev/null
+++ b/packages/cli/internal/core/template/node_test.go
@@ -0,0 +1,66 @@
+package template
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestNodePackageRenameIsBoundedAndKeepsDependencyOrder(t *testing.T) {
+ files := map[string][]byte{
+ "package.json": []byte(`{
+ "name": "starter",
+ "workspaces": ["packages/*"],
+ "scripts": { "dev": "pnpm -F @starter/preload build && pnpm -F @starter/preload-extra build" },
+ "custom": { "name": "@starter/preload" }
+}`),
+ "packages/preload/package.json": []byte(`{"name":"@starter/preload"}`),
+ "packages/ui/package.json": []byte(`{
+ "name": "@starter/ui",
+ "dependencies": {
+ "@starter/preload": "workspace:*",
+ "@vendor/lib": "^1.0.0"
+ },
+ "peerDependencies": { "@starter/preload": "workspace:*" },
+ "optionalDependencies": { "@starter/preload": "workspace:*" }
+}`),
+ "ui.ts": []byte("import '@starter/preload/channels';\n// @starter/preload\n"),
+ }
+ if err := rewriteNode(files, NodeSpec{Scope: "@starter", SourceFiles: []string{"ui.ts"}}, CommonVariables("Zulu_Desktop", "pnpm")); err != nil {
+ t.Fatal(err)
+ }
+ root := string(files["package.json"])
+ if !strings.Contains(root, "@zulu-desktop/preload build") || !strings.Contains(root, "@starter/preload-extra build") || !strings.Contains(root, `"custom": { "name": "@starter/preload" }`) {
+ t.Fatal(root)
+ }
+ if string(files["ui.ts"]) != "import '@zulu-desktop/preload/channels';\n// @zulu-desktop/preload\n" {
+ t.Fatal(string(files["ui.ts"]))
+ }
+ raw := string(files["packages/ui/package.json"])
+ if strings.Index(raw, `"@vendor/lib"`) > strings.Index(raw, `"@zulu-desktop/preload"`) {
+ t.Fatal("dependency order:", raw)
+ }
+ var pkg map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(raw), &pkg); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"dependencies", "peerDependencies", "optionalDependencies"} {
+ var deps map[string]string
+ if err := json.Unmarshal(pkg[key], &deps); err != nil {
+ t.Fatal(err)
+ }
+ if deps["@zulu-desktop/preload"] != "workspace:*" {
+ t.Fatal(key, deps)
+ }
+ }
+}
+
+func TestNodeRenameRejectsDependencyKeyCollision(t *testing.T) {
+ files := map[string][]byte{
+ "package.json": []byte(`{"name":"starter","workspaces":["packages/preload"],"dependencies":{"@starter/preload":"workspace:*","@target/preload":"1.0.0"}}`),
+ "packages/preload/package.json": []byte(`{"name":"@starter/preload"}`),
+ }
+ if err := rewriteNode(files, NodeSpec{Scope: "@starter"}, CommonVariables("target", "pnpm")); err == nil {
+ t.Fatal("overwrote external dependency")
+ }
+}
diff --git a/packages/cli/internal/core/template/now.go b/packages/cli/internal/core/template/now.go
deleted file mode 100644
index 18c85c5..0000000
--- a/packages/cli/internal/core/template/now.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package template
-
-import "time"
-
-// _now is wrapped in a func var so tests can stub it for deterministic year
-// values. Keep it private to the package.
-var _now = func() time.Time {
- return time.Now()
-}
diff --git a/packages/cli/internal/core/template/render.go b/packages/cli/internal/core/template/render.go
index bc64c5f..733ba2f 100644
--- a/packages/cli/internal/core/template/render.go
+++ b/packages/cli/internal/core/template/render.go
@@ -1,258 +1,200 @@
package template
import (
- "fmt"
+ "context"
"io/fs"
"os"
+ "path"
"path/filepath"
"regexp"
+ "sort"
"strings"
- "github.com/aymerick/raymond"
-
cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/templatefiles"
"github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled"
)
-// Variables is the merged variable bag passed to handlebars + path
-// interpolation. Mirrors TemplateVariables in TS — all values are strings;
-// users can extend the bag via template.json declarations.
+// Variables contains only the two supported starter substitutions.
type Variables map[string]string
-// CommonVariables returns the base variable bag every template starts with.
-// Users (or template.json) can override any of these.
-func CommonVariables(projectName, packageManager string) Variables {
- return Variables{
- "projectName": projectName,
- "projectNameCamelCase": toCamelCase(projectName),
- "projectNamePascalCase": toPascalCase(projectName),
- "projectNameKebabCase": toKebabCase(projectName),
- "author": "",
- "description": "",
- "license": "MIT",
- "year": fmt.Sprintf("%d", currentYear()),
- "packageManager": packageManager,
- }
-}
+var (
+ camelToWordsRE = regexp.MustCompile(`([a-z0-9])([A-Z])`)
+ nonAlphaNumRE = regexp.MustCompile(`[^a-zA-Z0-9]+`)
+)
-// excludedTemplateEntries are filenames the renderer never copies into
-// the destination. .git / node_modules are obvious; go.mod / go.sum are
-// dev-only module-isolation files for Go templates. Node lockfiles belong
-// to the destination workspace and must be resolved after all packages join it.
-var excludedTemplateEntries = map[string]struct{}{
- ".git": {},
- ".one": {},
- "node_modules": {},
- "AGENTS.md": {},
- "CLAUDE.md": {},
- "go.mod": {},
- "go.sum": {},
- "pnpm-lock.yaml": {},
- "package-lock.json": {},
- "npm-shrinkwrap.json": {},
- "yarn.lock": {},
- "bun.lock": {},
- "bun.lockb": {},
+// CommonVariables keeps the existing project-name normalization.
+func CommonVariables(projectName, _ string) Variables {
+ words := nonAlphaNumRE.Split(
+ camelToWordsRE.ReplaceAllString(strings.TrimSpace(projectName), "$1 $2"), -1)
+ var nonempty []string
+ for _, word := range words {
+ if word != "" {
+ nonempty = append(nonempty, strings.ToLower(word))
+ }
+ }
+ return Variables{"projectName": projectName, "projectNameKebabCase": strings.Join(nonempty, "-")}
}
-// pathVarRE matches the __varName__ placeholder syntax used in
-// directory and file names.
-var pathVarRE = regexp.MustCompile(`__([a-zA-Z0-9_]+)__`)
-
-// LocalTemplatePrefix is the registry-repo prefix marking a template that
-// ships inside the bundled templates tree (vs. a remote tarball).
const LocalTemplatePrefix = "local:"
-// Render copies the templateID's tree (from the embedded TemplatesFS) into
-// targetDir, applying handlebars to *.hbs files and path-variable substitution
-// to filenames. Mirrors renderTemplateIntoTarget in TS.
-//
-// templateID is the directory name under bundled.TemplatesRoot — e.g. for
-// the nestjs-api template, templateID = "nestjs-api". Callers normally derive
-// this from a registry entry's Repo field (parseLocalTemplateRepo).
-func Render(templateID string, targetDir string, vars Variables) error {
- root := filepath.ToSlash(filepath.Join(bundled.TemplatesRoot, templateID))
- // Sanity check that the directory exists in the embed FS.
- if _, err := fs.Stat(bundled.TemplatesFS, root); err != nil {
- return cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND,
- i18n.Tf("template.local_missing", templateID, templateID))
+// Render prepares all template files before publishing any of them. Creation
+// holds the workspace lock and remains responsible for workspace registration.
+func Render(templateID, targetDir string, vars Variables) error {
+ if err := validatePaths([]string{templateID}); err != nil {
+ return err
}
- if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ source, err := fs.Sub(bundled.TemplatesFS, path.Join(bundled.TemplatesRoot, templateID))
+ if err != nil {
return err
}
- return renderEmbeddedTree(root, targetDir, vars, true)
+ if _, err := fs.Stat(source, "."); err != nil {
+ return cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, i18n.Tf("template.local_missing", templateID, templateID))
+ }
+ files, err := prepare(source, vars)
+ if err != nil {
+ return i18n.Errorf("template.prepare_failed", templateID, err)
+ }
+ return publish(targetDir, files)
}
-// renderEmbeddedTree walks an FS subtree and writes the rendered output to
-// the host filesystem. isRoot is true only for the top-level invocation and
-// triggers special handling of template.json (skipped from copy).
-func renderEmbeddedTree(srcRoot string, dstRoot string, vars Variables, isRoot bool) error {
- entries, err := fs.ReadDir(bundled.TemplatesFS, srcRoot)
+func prepare(source fs.FS, vars Variables) (map[string][]byte, error) {
+ spec, err := readSpec(source)
if err != nil {
- return err
- }
- if err := os.MkdirAll(dstRoot, 0o755); err != nil {
- return err
+ return nil, err
}
- for _, entry := range entries {
- name := entry.Name()
- if _, skip := excludedTemplateEntries[name]; skip {
- continue
+ files := map[string][]byte{}
+ err = fs.WalkDir(source, ".", func(name string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
}
- if isRoot && !entry.IsDir() && name == "template.json" {
- continue
+ if name == "." {
+ return nil
}
- renderedName := replacePathVariables(name, vars)
- // Do not recreate retired workspace metadata through template variables.
- if entry.IsDir() && renderedName == ".one" {
- continue
+ logical := templatefiles.LogicalPath(name)
+ if templatefiles.Excluded(logical) || spec.excluded(logical) {
+ if entry.IsDir() {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if entry.Type()&fs.ModeSymlink != 0 {
+ return i18n.Errorf("template.spec_path", name)
}
- srcPath := filepath.ToSlash(filepath.Join(srcRoot, name))
if entry.IsDir() {
- dstPath := filepath.Join(dstRoot, renderedName)
- if err := renderEmbeddedTree(srcPath, dstPath, vars, false); err != nil {
- return err
- }
- continue
+ return nil
}
- // File: handlebars-render if .hbs, else copy as-is.
- isHbs := strings.HasSuffix(renderedName, ".hbs")
- dstName := renderedName
- if isHbs {
- dstName = strings.TrimSuffix(renderedName, ".hbs")
+ if !entry.Type().IsRegular() {
+ return i18n.Errorf("template.spec_path", name)
}
- // Agent instructions belong to the user, including templated filenames.
- if dstName == "AGENTS.md" || dstName == "CLAUDE.md" || isNodeLockfile(dstName) {
- continue
+ if logical == "template.json" {
+ return nil
}
- dstPath := filepath.Join(dstRoot, dstName)
- if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
+ if err := validatePaths([]string{logical}); err != nil {
return err
}
- raw, err := fs.ReadFile(bundled.TemplatesFS, srcPath)
- if err != nil {
- return err
+ if strings.HasSuffix(logical, ".hbs") {
+ return i18n.Errorf("template.legacy_file", logical)
}
- var body []byte
- if isHbs {
- rendered, rerr := renderHandlebars(string(raw), vars)
- if rerr != nil {
- return cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND,
- i18n.Tf("template.render_failed", srcPath, rerr))
- }
- body = []byte(rendered)
- } else {
- body = raw
+ if _, exists := files[logical]; exists {
+ return i18n.Errorf("template.file_collision", logical)
}
- if err := os.WriteFile(dstPath, body, copyMode(entry)); err != nil {
+ raw, err := fs.ReadFile(source, name)
+ if err != nil {
return err
}
- }
- return nil
-}
-
-// copyMode preserves the executable bit when an embedded file is .sh / hook.
-// embed.FS doesn't surface stat info, so we approximate by extension.
-func copyMode(d fs.DirEntry) os.FileMode {
- name := d.Name()
- if strings.HasSuffix(name, ".sh") || strings.Contains(name, "/.husky/") {
- return 0o755
- }
- return 0o644
-}
-
-func renderHandlebars(template string, vars Variables) (string, error) {
- tpl, err := raymond.Parse(template)
+ files[logical] = append([]byte{}, raw...)
+ return nil
+ })
if err != nil {
- return "", err
+ return nil, err
}
- // raymond expects map[string]interface{}; convert.
- ctx := make(map[string]interface{}, len(vars))
- for k, v := range vars {
- ctx[k] = v
+ if err := applyText(files, spec.Text, vars); err != nil {
+ return nil, err
}
- return tpl.Exec(ctx)
-}
-
-// replacePathVariables expands __varName__ placeholders in directory / file
-// names. Unknown variables pass through unchanged so users can include
-// double-underscore strings in template paths if they really need to.
-func replacePathVariables(name string, vars Variables) string {
- return pathVarRE.ReplaceAllStringFunc(name, func(segment string) string {
- match := pathVarRE.FindStringSubmatch(segment)
- if len(match) != 2 {
- return segment
- }
- if v, ok := vars[match[1]]; ok {
- return v
+ if spec.Go != nil {
+ if err := rewriteGo(files, *spec.Go, vars); err != nil {
+ return nil, err
}
- return segment
- })
-}
-
-// helpers below.
-
-func toCamelCase(s string) string {
- words := splitWords(s)
- if len(words) == 0 {
- return ""
}
- out := words[0]
- for _, w := range words[1:] {
- out += capFirst(w)
+ if spec.Node != nil {
+ if err := rewriteNode(files, *spec.Node, vars); err != nil {
+ return nil, err
+ }
}
- return out
+ return files, nil
}
-func toPascalCase(s string) string {
- words := splitWords(s)
- out := ""
- for _, w := range words {
- out += capFirst(w)
+func sortedFiles(files map[string][]byte) []string {
+ names := make([]string, 0, len(files))
+ for name := range files {
+ names = append(names, name)
}
- return out
+ sort.Strings(names)
+ return names
}
-func toKebabCase(s string) string {
- return strings.Join(splitWords(s), "-")
-}
-
-var (
- camelToWordsRE = regexp.MustCompile(`([a-z0-9])([A-Z])`)
- nonAlphaNumRE = regexp.MustCompile(`[^a-zA-Z0-9]+`)
-)
-
-func splitWords(s string) []string {
- s = strings.TrimSpace(s)
- s = camelToWordsRE.ReplaceAllString(s, "$1 $2")
- parts := nonAlphaNumRE.Split(s, -1)
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- if p == "" {
- continue
+func publish(target string, files map[string][]byte) (err error) {
+ target, err = filepath.Abs(target)
+ if err != nil {
+ return err
+ }
+ if info, statErr := os.Lstat(target); statErr == nil {
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return i18n.Errorf("template.spec_path", target)
+ }
+ entries, readErr := os.ReadDir(target)
+ if readErr != nil {
+ return readErr
+ }
+ if len(entries) > 0 {
+ return i18n.Errorf("creation.directory_exists", target)
}
- out = append(out, strings.ToLower(p))
+ } else if !os.IsNotExist(statErr) {
+ return statErr
}
- return out
-}
-
-func capFirst(s string) string {
- if s == "" {
- return s
+ plan := fsutil.NewFilePlan(target)
+ // Record only missing directories, removing them on failure only if empty.
+ // Never recursively remove files that another writer might have created.
+ missing := map[string]bool{}
+ for _, name := range sortedFiles(files) {
+ dest := filepath.Join(target, filepath.FromSlash(name))
+ if err := fsutil.SafeWritePath(target, dest); err != nil {
+ return err
+ }
+ for dir := filepath.Dir(dest); ; dir = filepath.Dir(dir) {
+ if _, statErr := os.Lstat(dir); os.IsNotExist(statErr) {
+ missing[dir] = true
+ }
+ if dir == target {
+ break
+ }
+ }
+ mode := os.FileMode(0o644)
+ if strings.HasSuffix(name, ".sh") {
+ mode = 0o755
+ }
+ if err := plan.Set(name, files[name], mode); err != nil {
+ return err
+ }
}
- return strings.ToUpper(s[:1]) + s[1:]
-}
-
-// currentYear is a package-level seam for tests; production calls time.Now().
-var currentYear = func() int {
- return _now().Year()
-}
-
-func isNodeLockfile(name string) bool {
- switch name {
- case "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "bun.lock", "bun.lockb":
- return true
+ defer func() {
+ if err == nil {
+ return
+ }
+ dirs := make([]string, 0, len(missing))
+ for dir := range missing {
+ dirs = append(dirs, dir)
+ }
+ sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) })
+ for _, dir := range dirs {
+ _ = os.Remove(dir)
+ }
+ }()
+ if len(files) == 0 {
+ return os.MkdirAll(target, 0o755)
}
- return false
+ return plan.Apply(context.Background())
}
diff --git a/packages/cli/internal/core/template/render_test.go b/packages/cli/internal/core/template/render_test.go
new file mode 100644
index 0000000..0fc4dd4
--- /dev/null
+++ b/packages/cli/internal/core/template/render_test.go
@@ -0,0 +1,183 @@
+package template
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled"
+)
+
+func TestGoStarterEditsOnlyModuleAndImports(t *testing.T) {
+ source := fstest.MapFS{
+ "template.json": {Data: []byte(`{"schemaVersion":1,"go":{"modulePrefix":"example.com/"}}`)},
+ "_go.mod": {Data: []byte("module example.com/starter\n\ngo 1.26.0\n")},
+ "go.sum": {Data: []byte("checksums\n")},
+ "main_windows.go": {Data: []byte("//go:build windows\n\npackage main\n\nimport (\n alias \"example.com/starter/lib\"\n _ \"example.com/starter-extra/lib\"\n)\n// example.com/starter remains a comment.\nconst value = \"example.com/starter/lib\"\n")},
+ "__tests__/empty": {Data: []byte{}},
+ "icon.png": {Data: []byte{0, 0xff, 1}},
+ }
+ files, err := prepare(source, CommonVariables("My_API", ""))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := strings.Replace(string(source["main_windows.go"].Data), `alias "example.com/starter/lib"`, `alias "example.com/my-api/lib"`, 1)
+ if string(files["main_windows.go"]) != want {
+ t.Fatalf("unrelated Go content changed:\n%s", files["main_windows.go"])
+ }
+ if !strings.Contains(string(files["go.mod"]), "module example.com/my-api") {
+ t.Fatal(string(files["go.mod"]))
+ }
+ if !bytes.Equal(files["go.sum"], source["go.sum"].Data) || !bytes.Equal(files["icon.png"], source["icon.png"].Data) {
+ t.Fatal("copied files changed")
+ }
+ target := filepath.Join(t.TempDir(), "output")
+ if err := publish(target, files); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(filepath.Join(target, "__tests__/empty"))
+ if err != nil || info.Size() != 0 {
+ t.Fatalf("empty file lost: %v", err)
+ }
+ source["nested/_go.mod"] = &fstest.MapFile{Data: []byte("module example.com/other")}
+ if _, err := prepare(source, CommonVariables("test", "")); err == nil {
+ t.Fatal("nested module accepted")
+ }
+}
+
+func TestTextRulesUseOriginalBytes(t *testing.T) {
+ source := fstest.MapFS{
+ "template.json": {Data: []byte(`{"schemaVersion":1,"text":[{"files":["README.md"],"from":"FIRST","value":"projectName"},{"files":["README.md"],"from":"SECOND","value":"projectNameKebabCase"}]}`)},
+ "README.md": {Data: []byte("FIRST SECOND\n")},
+ }
+ files, err := prepare(source, Variables{"projectName": "SECOND", "projectNameKebabCase": "final"})
+ if err != nil || string(files["README.md"]) != "SECOND final\n" {
+ t.Fatalf("cascading replacement: %s %v", files["README.md"], err)
+ }
+}
+
+func TestInvalidTemplatesFailDuringPreparation(t *testing.T) {
+ tests := []struct {
+ name, spec string
+ extra fstest.MapFS
+ }{
+ {"unknown field", `{"schemaVersion":1,"command":"echo unsafe"}`, nil},
+ {"unknown version", `{"schemaVersion":2}`, nil},
+ {"trailing JSON", `{"schemaVersion":1} {}`, nil},
+ {"duplicate JSON key", `{"schemaVersion":1,"schemaVersion":1}`, nil},
+ {"escape", `{"schemaVersion":1,"exclude":["../secret"]}`, nil},
+ {"absolute", `{"schemaVersion":1,"exclude":["/secret"]}`, nil},
+ {"missing file", `{"schemaVersion":1,"text":[{"files":["missing"],"from":"old","value":"projectName"}]}`, nil},
+ {"missing match", `{"schemaVersion":1,"text":[{"files":["README.md"],"from":"absent","value":"projectName"}]}`, nil},
+ {"overlap", `{"schemaVersion":1,"text":[{"files":["README.md"],"from":"old","value":"projectName"},{"files":["README.md"],"from":"old","value":"projectNameKebabCase"}]}`, nil},
+ {"binary", `{"schemaVersion":1,"text":[{"files":["image"],"from":"old","value":"projectName"}]}`, fstest.MapFS{"image": {Data: []byte{0xff, 0}}}},
+ {"collision", `{"schemaVersion":1}`, fstest.MapFS{"go.mod": {Data: []byte("module m")}, "_go.mod": {Data: []byte("module m")}}},
+ {"legacy file", `{"schemaVersion":1}`, fstest.MapFS{"file.hbs": {Data: []byte("old")}}},
+ {"symlink", `{"schemaVersion":1}`, fstest.MapFS{"link": {Data: []byte("/outside"), Mode: fs.ModeSymlink}}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ source := fstest.MapFS{"template.json": {Data: []byte(tc.spec)}, "README.md": {Data: []byte("old\n")}}
+ for name, file := range tc.extra {
+ source[name] = file
+ }
+ if _, err := prepare(source, CommonVariables("new", "pnpm")); err == nil {
+ t.Fatal("invalid template accepted")
+ }
+ })
+ }
+}
+
+func TestPublishPreservesExistingDirectory(t *testing.T) {
+ root := t.TempDir()
+ file := filepath.Join(root, "keep")
+ if err := os.WriteFile(file, []byte("user"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := publish(root, map[string][]byte{"keep": []byte("template")}); err == nil {
+ t.Fatal("overwrote existing directory")
+ }
+ raw, _ := os.ReadFile(file)
+ if string(raw) != "user" {
+ t.Fatal("user data changed")
+ }
+}
+
+func TestAllBundledStartersPrepareWithRealNames(t *testing.T) {
+ registry, err := Fetch(context.Background(), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range registry.Templates {
+ for _, name := range []string{"sample", "My_API", "billing-service"} {
+ t.Run(entry.ID+"/"+name, func(t *testing.T) {
+ source, err := fs.Sub(bundled.TemplatesFS, path.Join(bundled.TemplatesRoot, entry.ID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ files, err := prepare(source, CommonVariables(name, "pnpm"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name := range files {
+ if strings.HasSuffix(name, ".hbs") || name == "template.json" || name == "_go.mod" || name == "go.work" {
+ t.Fatalf("development file delivered: %s", name)
+ }
+ }
+ if entry.Toolchain == "node" {
+ var pkg struct{ Name string }
+ if err := json.Unmarshal(files["package.json"], &pkg); err != nil {
+ t.Fatal(err)
+ }
+ if pkg.Name == "" {
+ t.Fatal("invalid package")
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestTemplateErrorsFollowLanguageAndRetainCause(t *testing.T) {
+ original := i18n.Active()
+ t.Cleanup(func() { _ = i18n.Init(original) })
+ for _, tc := range []struct{ locale, want string }{{"en-US", "Unsupported template"}, {"zh-CN", "不支持的模板"}} {
+ if err := i18n.Init(tc.locale); err != nil {
+ t.Fatal(err)
+ }
+ _, err := prepare(fstest.MapFS{"template.json": {Data: []byte(`{"schemaVersion":2}`)}}, nil)
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("%s: %v", tc.locale, err)
+ }
+ cause := os.ErrPermission
+ wrapped := i18n.Errorf("template.prepare_failed", "go-api", cause)
+ if !errors.Is(wrapped, cause) {
+ t.Fatal("lost error chain")
+ }
+ }
+}
+
+func TestPublishRollsBackFailedFileWrites(t *testing.T) {
+ root := t.TempDir()
+ err := publish(root, map[string][]byte{
+ "a": []byte("first"),
+ "z": []byte("file blocks the following directory"),
+ "z/child": []byte("cannot create"),
+ })
+ if err == nil {
+ t.Fatal("expected write failure")
+ }
+ entries, readErr := os.ReadDir(root)
+ if readErr != nil || len(entries) != 0 {
+ t.Fatalf("partial files remain: %v %v", entries, readErr)
+ }
+}
diff --git a/packages/cli/internal/core/template/spec.go b/packages/cli/internal/core/template/spec.go
new file mode 100644
index 0000000..6f57b32
--- /dev/null
+++ b/packages/cli/internal/core/template/spec.go
@@ -0,0 +1,104 @@
+package template
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "io/fs"
+ "strings"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/jsonedit"
+)
+
+// Spec describes a runnable starter. No scripts or expressions are evaluated.
+type Spec struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Go *GoSpec `json:"go,omitempty"`
+ Node *NodeSpec `json:"node,omitempty"`
+ Text []TextRule `json:"text,omitempty"`
+ Exclude []string `json:"exclude,omitempty"`
+}
+type GoSpec struct {
+ ModulePrefix string `json:"modulePrefix"`
+}
+type NodeSpec struct {
+ Scope string `json:"scope"`
+ SourceFiles []string `json:"sourceFiles"`
+}
+type TextRule struct {
+ Files []string `json:"files"`
+ From string `json:"from"`
+ Value string `json:"value"`
+ MinMatches *int `json:"minMatches,omitempty"`
+}
+
+func readSpec(source fs.FS) (Spec, error) {
+ raw, err := fs.ReadFile(source, "template.json")
+ if errors.Is(err, fs.ErrNotExist) {
+ return Spec{}, nil
+ }
+ if err != nil {
+ return Spec{}, err
+ }
+ if _, err := jsonedit.RewriteStrings(raw, func(_ []string, _ bool, value string) (string, error) { return value, nil }); err != nil {
+ return Spec{}, i18n.Errorf("template.spec_parse", err)
+ }
+ var spec Spec
+ d := json.NewDecoder(bytes.NewReader(raw))
+ d.DisallowUnknownFields()
+ if err := d.Decode(&spec); err != nil {
+ return spec, i18n.Errorf("template.spec_parse", err)
+ }
+ if err := d.Decode(new(any)); err != io.EOF {
+ return spec, i18n.Errorf("template.spec_invalid", "template.json")
+ }
+ if spec.SchemaVersion != 1 {
+ return spec, i18n.Errorf("template.spec_version", spec.SchemaVersion)
+ }
+ if spec.Go != nil && (!strings.HasSuffix(spec.Go.ModulePrefix, "/") || spec.Go.ModulePrefix == "/") {
+ return spec, i18n.Errorf("template.spec_invalid", "go.modulePrefix")
+ }
+ if spec.Go != nil && spec.Node != nil {
+ return spec, i18n.Errorf("template.spec_invalid", "go/node")
+ }
+ if spec.Node != nil {
+ if !scopeRE.MatchString(spec.Node.Scope) {
+ return spec, i18n.Errorf("template.spec_invalid", "node.scope")
+ }
+ if err := validatePaths(spec.Node.SourceFiles); err != nil {
+ return spec, err
+ }
+ }
+ if err := validatePaths(spec.Exclude); err != nil {
+ return spec, err
+ }
+ for _, rule := range spec.Text {
+ if rule.From == "" || len(rule.Files) == 0 || (rule.Value != "projectName" && rule.Value != "projectNameKebabCase") || (rule.MinMatches != nil && *rule.MinMatches < 1) {
+ return spec, i18n.Errorf("template.spec_invalid", "text")
+ }
+ if err := validatePaths(rule.Files); err != nil {
+ return spec, err
+ }
+ }
+ return spec, nil
+}
+func validatePaths(paths []string) error {
+ seen := map[string]bool{}
+ for _, name := range paths {
+ if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "\\:*?") || seen[name] {
+ return i18n.Errorf("template.spec_path", name)
+ }
+ seen[name] = true
+ }
+ return nil
+}
+func (s Spec) excluded(name string) bool {
+ for _, excluded := range s.Exclude {
+ if name == excluded || strings.HasPrefix(name, excluded+"/") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/packages/cli/internal/core/template/transform.go b/packages/cli/internal/core/template/transform.go
new file mode 100644
index 0000000..c4b7d40
--- /dev/null
+++ b/packages/cli/internal/core/template/transform.go
@@ -0,0 +1,145 @@
+package template
+
+import (
+ "bytes"
+ "go/parser"
+ "go/token"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "golang.org/x/mod/modfile"
+ "golang.org/x/mod/module"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+type byteEdit struct {
+ start, end int
+ value string
+}
+
+func editBytes(raw []byte, edits []byteEdit, name string) ([]byte, error) {
+ sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start })
+ var out bytes.Buffer
+ pos := 0
+ for _, edit := range edits {
+ if edit.start < pos {
+ return nil, i18n.Errorf("template.text_overlap", name)
+ }
+ out.Write(raw[pos:edit.start])
+ out.WriteString(edit.value)
+ pos = edit.end
+ }
+ out.Write(raw[pos:])
+ return out.Bytes(), nil
+}
+
+func applyText(files map[string][]byte, rules []TextRule, vars Variables) error {
+ edits := map[string][]byteEdit{}
+ for _, rule := range rules {
+ value, ok := vars[rule.Value]
+ if !ok {
+ return i18n.Errorf("template.variable_missing", rule.Value)
+ }
+ for _, name := range rule.Files {
+ raw, ok := files[name]
+ if !ok {
+ return i18n.Errorf("template.file_missing", name)
+ }
+ if !utf8.Valid(raw) || bytes.IndexByte(raw, 0) >= 0 {
+ return i18n.Errorf("template.text_invalid", name)
+ }
+ count := 0
+ for start := 0; start < len(raw); {
+ i := bytes.Index(raw[start:], []byte(rule.From))
+ if i < 0 {
+ break
+ }
+ i += start
+ edits[name] = append(edits[name], byteEdit{i, i + len(rule.From), value})
+ start = i + len(rule.From)
+ count++
+ }
+ minimum := 1
+ if rule.MinMatches != nil {
+ minimum = *rule.MinMatches
+ }
+ if count < minimum {
+ return i18n.Errorf("template.text_matches", name, rule.From, minimum, count)
+ }
+ }
+ }
+ for _, name := range sortedFiles(files) {
+ if len(edits[name]) == 0 {
+ continue
+ }
+ after, err := editBytes(files[name], edits[name], name)
+ if err != nil {
+ return err
+ }
+ files[name] = after
+ }
+ return nil
+}
+
+func rewriteGo(files map[string][]byte, spec GoSpec, vars Variables) error {
+ raw, ok := files["go.mod"]
+ if !ok {
+ return i18n.Errorf("template.file_missing", "go.mod")
+ }
+ mod, err := modfile.Parse("go.mod", raw, nil)
+ if err != nil {
+ return i18n.Errorf("template.transform_failed", "go.mod", err)
+ }
+ if mod.Module == nil {
+ return i18n.Errorf("template.spec_invalid", "go.mod: module")
+ }
+ old := mod.Module.Mod.Path
+ target := spec.ModulePrefix + vars["projectNameKebabCase"]
+ if err := module.CheckPath(target); err != nil {
+ return i18n.Errorf("template.transform_failed", "go.mod", err)
+ }
+ if err := mod.AddModuleStmt(target); err != nil {
+ return err
+ }
+ files["go.mod"], err = mod.Format()
+ if err != nil {
+ return err
+ }
+ for _, name := range sortedFiles(files) {
+ if strings.HasSuffix(name, "/go.mod") {
+ return i18n.Errorf("template.nested_module", name)
+ }
+ if !strings.HasSuffix(name, ".go") {
+ continue
+ }
+ raw := files[name]
+ fset := token.NewFileSet()
+ parsed, err := parser.ParseFile(fset, name, raw, parser.ImportsOnly)
+ if err != nil {
+ return i18n.Errorf("template.transform_failed", name, err)
+ }
+ var edits []byteEdit
+ for _, imp := range parsed.Imports {
+ value, err := strconv.Unquote(imp.Path.Value)
+ if err != nil {
+ return err
+ }
+ if value != old && !strings.HasPrefix(value, old+"/") {
+ continue
+ }
+ start := fset.Position(imp.Path.Pos()).Offset
+ end := fset.Position(imp.Path.End()).Offset
+ edits = append(edits, byteEdit{start, end, strconv.Quote(target + strings.TrimPrefix(value, old))})
+ }
+ if len(edits) > 0 {
+ files[name], err = editBytes(raw, edits, name)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/packages/cli/internal/modules/creation/json_fields.go b/packages/cli/internal/modules/creation/json_fields.go
index 77def37..23e1f0b 100644
--- a/packages/cli/internal/modules/creation/json_fields.go
+++ b/packages/cli/internal/modules/creation/json_fields.go
@@ -1,119 +1,12 @@
package creation
import (
- "bytes"
"encoding/json"
- "sort"
- "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/jsonedit"
)
-// marshalJSONValue retains shell operators and version ranges as readable JSON
-// strings instead of escaping &, < and > for embedding in HTML.
-func marshalJSONValue(value any) ([]byte, error) {
- var b bytes.Buffer
- encoder := json.NewEncoder(&b)
- encoder.SetEscapeHTML(false)
- if err := encoder.Encode(value); err != nil {
- return nil, err
- }
- return bytes.TrimSuffix(b.Bytes(), []byte("\n")), nil
-}
-
-// updateJSONFields edits immediate object members, leaving all other bytes
-// intact. A nil value removes a field; json.RawMessage("null") sets JSON null.
-// This preserves a template's indentation, ordering and compact arrays without
-// requiring Node or a formatter to be installed during project creation.
+func marshalJSONValue(value any) ([]byte, error) { return jsonedit.Marshal(value) }
func updateJSONFields(raw []byte, updates map[string]json.RawMessage) ([]byte, error) {
- keys := make([]string, 0, len(updates))
- for key := range updates {
- keys = append(keys, key)
- }
- sort.Strings(keys)
- for _, key := range keys {
- var err error
- raw, err = updateJSONField(raw, key, updates[key])
- if err != nil {
- return nil, err
- }
- }
- return raw, nil
-}
-
-type jsonField struct {
- key string
- keyStart, valueStart, end int
-}
-
-func updateJSONField(raw []byte, key string, value json.RawMessage) ([]byte, error) {
- if !json.Valid(raw) || value != nil && !json.Valid(value) {
- return nil, i18n.Errorf("creation.json_invalid", key)
- }
- decoder := json.NewDecoder(bytes.NewReader(raw))
- token, _ := decoder.Token()
- if token != json.Delim('{') {
- return nil, i18n.Errorf("creation.json_object")
- }
- openEnd := int(decoder.InputOffset())
- var fields []jsonField
- seen := make(map[string]bool)
- for decoder.More() {
- start := int(decoder.InputOffset())
- for raw[start] == ',' || raw[start] == ' ' || raw[start] == '\t' || raw[start] == '\r' || raw[start] == '\n' {
- start++
- }
- token, err := decoder.Token()
- if err != nil {
- return nil, err
- }
- fieldKey := token.(string)
- if seen[fieldKey] {
- return nil, i18n.Errorf("creation.json_duplicate", fieldKey)
- }
- seen[fieldKey] = true
- var fieldValue json.RawMessage
- if err := decoder.Decode(&fieldValue); err != nil {
- return nil, err
- }
- end := int(decoder.InputOffset())
- fields = append(fields, jsonField{fieldKey, start, end - len(fieldValue), end})
- }
- _, _ = decoder.Token()
- closeStart := int(decoder.InputOffset()) - 1
- for i, field := range fields {
- if field.key != key {
- continue
- }
- if value != nil {
- return replaceJSONBytes(raw, field.valueStart, field.end, value), nil
- }
- if len(fields) == 1 {
- return replaceJSONBytes(raw, openEnd, closeStart, nil), nil
- }
- if i == 0 {
- return replaceJSONBytes(raw, field.keyStart, fields[1].keyStart, nil), nil
- }
- return replaceJSONBytes(raw, fields[i-1].end, field.end, nil), nil
- }
- if value == nil {
- return raw, nil
- }
- encodedKey, _ := json.Marshal(key)
- member := append(append(encodedKey, ':', ' '), value...)
- if len(fields) == 0 {
- return replaceJSONBytes(raw, openEnd, closeStart, member), nil
- }
- // Reuse the first member's leading whitespace, including CRLF or tabs.
- prefix := raw[openEnd:fields[0].keyStart]
- addition := append([]byte{','}, prefix...)
- addition = append(addition, member...)
- end := fields[len(fields)-1].end
- return replaceJSONBytes(raw, end, end, addition), nil
-}
-
-func replaceJSONBytes(raw []byte, start, end int, replacement []byte) []byte {
- after := make([]byte, 0, len(raw)+len(replacement)-(end-start))
- after = append(after, raw[:start]...)
- after = append(after, replacement...)
- return append(after, raw[end:]...)
+ return jsonedit.UpdateFields(raw, updates)
}
diff --git a/packages/cli/internal/modules/creation/runnable_templates_test.go b/packages/cli/internal/modules/creation/runnable_templates_test.go
new file mode 100644
index 0000000..43bce7c
--- /dev/null
+++ b/packages/cli/internal/modules/creation/runnable_templates_test.go
@@ -0,0 +1,99 @@
+package creation
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain"
+)
+
+// This dependency-backed gate is explicit so ordinary Go unit tests remain
+// offline. mise run check:templates and the Linux CI job enable it.
+func TestRunnableTemplateSourcesAndProjects(t *testing.T) {
+ if os.Getenv("ONE_TEST_TEMPLATE_BUILDS") != "1" {
+ t.Skip("run mise run check:templates")
+ }
+ _, here, _, _ := runtime.Caller(0)
+ repo := filepath.Clean(filepath.Join(filepath.Dir(here), "../../../../.."))
+ sources := filepath.Join(repo, "packages/templates")
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+ defer cancel()
+ run := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "CI=true")
+ t.Logf("%s: %s", dir, strings.Join(args, " "))
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v\n%s", err, out)
+ }
+ }
+ // Local go.work files make ordinary commands in the source directories work.
+ for _, id := range []string{"go-lib", "go-api"} {
+ run(filepath.Join(sources, id), "go", "test", "-mod=readonly", "./...")
+ run(filepath.Join(sources, id), "go", "build", "-mod=readonly", "./...")
+ }
+ desktopSource := filepath.Join(sources, "electron-app")
+ run(desktopSource, "pnpm", "install", "--no-frozen-lockfile")
+ run(desktopSource, "pnpm", "run", "format:check")
+ run(desktopSource, "pnpm", "run", "build")
+ formatter := filepath.Join(desktopSource, "node_modules/.bin/oxfmt")
+ if runtime.GOOS == "windows" {
+ formatter += ".cmd"
+ }
+ t.Setenv("ONE_TEST_OXFMT_BINARY", formatter)
+ internaltoolchain.RegisterBundled()
+ TestGeneratedNodeProjectsPassFormatting(t)
+
+ // Exercise a user-owned workspace with the repository's installed pnpm.
+ // The generator must honor this configuration rather than the starter pin.
+ s := newCreationService(t)
+ root := filepath.Join(t.TempDir(), "workspace with spaces")
+ if _, err := s.CreateWorkspace(ctx, WorkspaceInput{TargetDir: root, Name: "runnable"}); err != nil {
+ t.Fatal(err)
+ }
+ sourcePackage, err := os.ReadFile(filepath.Join(desktopSource, "package.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ manager := packageManagerFromTestJSON(t, sourcePackage)
+ raw := []byte("{\"private\":true,\"packageManager\":" + manager + "}\n")
+ if err := os.WriteFile(filepath.Join(root, "package.json"), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "pnpm-workspace.yaml"), []byte("packages: []\nonlyBuiltDependencies: [electron]\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ for _, project := range []struct{ id, name string }{{"go-api", "api"}, {"go-lib", "shared"}, {"electron-app", "Alpha_Desktop"}, {"electron-app", "zulu-desktop"}} {
+ if err := addLanguageProject(t, s, root, project.id, project.name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ run(root, "pnpm", "install", "--no-frozen-lockfile")
+ for _, dir := range []string{"services/api", "packages/shared"} {
+ run(filepath.Join(root, dir), "go", "test", "-mod=readonly", "./...")
+ }
+ for _, dir := range []string{"apps/Alpha_Desktop", "apps/zulu-desktop"} {
+ run(filepath.Join(root, dir), "pnpm", "run", "build")
+ run(filepath.Join(root, dir), formatter, "--check", ".")
+ }
+}
+
+func packageManagerFromTestJSON(t *testing.T, raw []byte) string {
+ t.Helper()
+ var pkg map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &pkg); err != nil {
+ t.Fatal(err)
+ }
+ if len(pkg["packageManager"]) == 0 {
+ t.Fatal("missing source packageManager")
+ }
+ return string(pkg["packageManager"])
+}
diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json
index 59dbafc..0e9e9dd 100644
--- a/packages/cli/internal/platform/i18n/locales/en-US.json
+++ b/packages/cli/internal/platform/i18n/locales/en-US.json
@@ -328,7 +328,20 @@
"registry.domains_invalid": "Registry template domains are invalid (id=%s).",
"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",
+ "template.prepare_failed": "Could not prepare template %s: %w",
+ "template.spec_parse": "Could not parse template.json: %w",
+ "template.spec_invalid": "Invalid template setting: %s. Check template.json and the starter files.",
+ "template.spec_version": "Unsupported template schema version: %d. Expected version 1.",
+ "template.spec_path": "Invalid template path: %s. Use a relative path within the template without symlinks.",
+ "template.legacy_file": "Legacy template file %s is not supported. Convert it to a runnable source file.",
+ "template.file_collision": "Template names collide: %s. Use distinct file and package names.",
+ "template.file_missing": "Required template file is missing: %s. Restore the file or update template.json.",
+ "template.text_invalid": "Template file %s is not UTF-8 text. Copy binary assets without text rules.",
+ "template.text_overlap": "Text replacement rules overlap in %s. Use distinct source text.",
+ "template.text_matches": "Template file %s must contain %q at least %d time(s); found %d. Update the source or template.json.",
+ "template.variable_missing": "Template variable %s is missing.",
+ "template.transform_failed": "Could not update template file %s: %w",
+ "template.nested_module": "Nested Go modules are not supported in templates: %s.",
"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.",
diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json
index f5a31d2..313e9f1 100644
--- a/packages/cli/internal/platform/i18n/locales/zh-CN.json
+++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json
@@ -328,7 +328,20 @@
"registry.domains_invalid": "registry.json 模板 domains 非法(id=%s)。",
"registry.field_missing": "缺少 %s",
"template.local_missing": "本地模板不存在:%s。请确认 templates/%s 已存在。",
- "template.render_failed": "模板渲染失败 %s: %v",
+ "template.prepare_failed": "无法准备模板 %s:%w",
+ "template.spec_parse": "无法解析 template.json:%w",
+ "template.spec_invalid": "模板配置无效:%s。请检查 template.json 和模板源文件。",
+ "template.spec_version": "不支持的模板格式版本:%d。需要版本 1。",
+ "template.spec_path": "模板路径无效:%s。请使用模板内不含符号链接的相对路径。",
+ "template.legacy_file": "不支持旧格式模板文件 %s。请将其转换为可运行的源文件。",
+ "template.file_collision": "模板名称冲突:%s。请使用不同的文件名和包名。",
+ "template.file_missing": "模板缺少必需文件:%s。请恢复文件或更新 template.json。",
+ "template.text_invalid": "模板文件 %s 不是 UTF-8 文本。二进制资源应直接复制,不应应用文本规则。",
+ "template.text_overlap": "%s 中的文本替换规则重叠。请使用不同的源文本。",
+ "template.text_matches": "模板文件 %s 中的 %q 至少需要出现 %d 次,实际为 %d 次。请更新源文件或 template.json。",
+ "template.variable_missing": "缺少模板变量 %s。",
+ "template.transform_failed": "无法更新模板文件 %s:%w",
+ "template.nested_module": "模板暂不支持嵌套 Go 模块:%s。",
"manifest.parse_failed": "one.manifest.json 解析失败。",
"manifest.retired_fields": "container 和 deploy 功能已下线,请手动删除 one.manifest.json 中对应的配置字段后重试。",
"manifest.version_unsupported": "one.manifest.json 版本 %d 不支持,当前 CLI 仅认 v%d。",
diff --git a/packages/cli/internal/platform/jsonedit/fields.go b/packages/cli/internal/platform/jsonedit/fields.go
new file mode 100644
index 0000000..13ff3b4
--- /dev/null
+++ b/packages/cli/internal/platform/jsonedit/fields.go
@@ -0,0 +1,119 @@
+package jsonedit
+
+import (
+ "bytes"
+ "encoding/json"
+ "sort"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+// Marshal retains shell operators and version ranges as readable JSON
+// strings instead of escaping &, < and > for embedding in HTML.
+func Marshal(value any) ([]byte, error) {
+ var b bytes.Buffer
+ encoder := json.NewEncoder(&b)
+ encoder.SetEscapeHTML(false)
+ if err := encoder.Encode(value); err != nil {
+ return nil, err
+ }
+ return bytes.TrimSuffix(b.Bytes(), []byte("\n")), nil
+}
+
+// UpdateFields edits immediate object members, leaving all other bytes
+// intact. A nil value removes a field; json.RawMessage("null") sets JSON null.
+// This preserves a template's indentation, ordering and compact arrays without
+// requiring Node or a formatter to be installed during project creation.
+func UpdateFields(raw []byte, updates map[string]json.RawMessage) ([]byte, error) {
+ keys := make([]string, 0, len(updates))
+ for key := range updates {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ var err error
+ raw, err = updateJSONField(raw, key, updates[key])
+ if err != nil {
+ return nil, err
+ }
+ }
+ return raw, nil
+}
+
+type jsonField struct {
+ key string
+ keyStart, valueStart, end int
+}
+
+func updateJSONField(raw []byte, key string, value json.RawMessage) ([]byte, error) {
+ if !json.Valid(raw) || value != nil && !json.Valid(value) {
+ return nil, i18n.Errorf("creation.json_invalid", key)
+ }
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ token, _ := decoder.Token()
+ if token != json.Delim('{') {
+ return nil, i18n.Errorf("creation.json_object")
+ }
+ openEnd := int(decoder.InputOffset())
+ var fields []jsonField
+ seen := make(map[string]bool)
+ for decoder.More() {
+ start := int(decoder.InputOffset())
+ for raw[start] == ',' || raw[start] == ' ' || raw[start] == '\t' || raw[start] == '\r' || raw[start] == '\n' {
+ start++
+ }
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ fieldKey := token.(string)
+ if seen[fieldKey] {
+ return nil, i18n.Errorf("creation.json_duplicate", fieldKey)
+ }
+ seen[fieldKey] = true
+ var fieldValue json.RawMessage
+ if err := decoder.Decode(&fieldValue); err != nil {
+ return nil, err
+ }
+ end := int(decoder.InputOffset())
+ fields = append(fields, jsonField{fieldKey, start, end - len(fieldValue), end})
+ }
+ _, _ = decoder.Token()
+ closeStart := int(decoder.InputOffset()) - 1
+ for i, field := range fields {
+ if field.key != key {
+ continue
+ }
+ if value != nil {
+ return replaceJSONBytes(raw, field.valueStart, field.end, value), nil
+ }
+ if len(fields) == 1 {
+ return replaceJSONBytes(raw, openEnd, closeStart, nil), nil
+ }
+ if i == 0 {
+ return replaceJSONBytes(raw, field.keyStart, fields[1].keyStart, nil), nil
+ }
+ return replaceJSONBytes(raw, fields[i-1].end, field.end, nil), nil
+ }
+ if value == nil {
+ return raw, nil
+ }
+ encodedKey, _ := json.Marshal(key)
+ member := append(append(encodedKey, ':', ' '), value...)
+ if len(fields) == 0 {
+ return replaceJSONBytes(raw, openEnd, closeStart, member), nil
+ }
+ // Reuse the first member's leading whitespace, including CRLF or tabs.
+ prefix := raw[openEnd:fields[0].keyStart]
+ addition := append([]byte{','}, prefix...)
+ addition = append(addition, member...)
+ end := fields[len(fields)-1].end
+ return replaceJSONBytes(raw, end, end, addition), nil
+}
+
+func replaceJSONBytes(raw []byte, start, end int, replacement []byte) []byte {
+ after := make([]byte, 0, len(raw)+len(replacement)-(end-start))
+ after = append(after, raw[:start]...)
+ after = append(after, replacement...)
+ return append(after, raw[end:]...)
+}
diff --git a/packages/cli/internal/platform/jsonedit/sort.go b/packages/cli/internal/platform/jsonedit/sort.go
new file mode 100644
index 0000000..ece5658
--- /dev/null
+++ b/packages/cli/internal/platform/jsonedit/sort.go
@@ -0,0 +1,58 @@
+package jsonedit
+
+import (
+ "bytes"
+ "encoding/json"
+ "sort"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+// SortKeys orders an object's immediate members, retaining their original
+// value bytes and the object's whitespace. Used for dependency keys that have
+// been renamed, whose alphabetical order otherwise changes during scaffolding.
+func SortKeys(raw []byte) ([]byte, error) {
+ if !json.Valid(raw) {
+ return nil, i18n.Errorf("creation.json_invalid", "")
+ }
+ d := json.NewDecoder(bytes.NewReader(raw))
+ tok, _ := d.Token()
+ if tok != json.Delim('{') {
+ return nil, i18n.Errorf("creation.json_object")
+ }
+ type member struct {
+ key string
+ start, end int
+ }
+ var fields []member
+ for d.More() {
+ start := int(d.InputOffset())
+ for raw[start] == ' ' || raw[start] == '\r' || raw[start] == '\n' || raw[start] == '\t' || raw[start] == ',' {
+ start++
+ }
+ tok, err := d.Token()
+ if err != nil {
+ return nil, err
+ }
+ var value json.RawMessage
+ if err := d.Decode(&value); err != nil {
+ return nil, err
+ }
+ fields = append(fields, member{tok.(string), start, int(d.InputOffset())})
+ }
+ if len(fields) < 2 {
+ return raw, nil
+ }
+ ordered := append([]member{}, fields...)
+ sort.Slice(ordered, func(i, j int) bool { return ordered[i].key < ordered[j].key })
+ var out bytes.Buffer
+ out.Write(raw[:fields[0].start])
+ for i, field := range ordered {
+ if i > 0 {
+ out.Write(raw[fields[i-1].end:fields[i].start])
+ }
+ out.Write(raw[field.start:field.end])
+ }
+ out.Write(raw[fields[len(fields)-1].end:])
+ return out.Bytes(), nil
+}
diff --git a/packages/cli/internal/platform/jsonedit/sort_test.go b/packages/cli/internal/platform/jsonedit/sort_test.go
new file mode 100644
index 0000000..cd80f2d
--- /dev/null
+++ b/packages/cli/internal/platform/jsonedit/sort_test.go
@@ -0,0 +1,16 @@
+package jsonedit
+
+import "testing"
+
+func TestSortDependencyKeysPreservesValuesAndWhitespace(t *testing.T) {
+ raw := "{\r\n\t\"z\": \"workspace:*\",\r\n\t\"a\": { \"value\": \"<>&\" }\r\n}"
+ want := "{\r\n\t\"a\": { \"value\": \"<>&\" },\r\n\t\"z\": \"workspace:*\"\r\n}"
+ got, err := SortKeys([]byte(raw))
+ if err != nil || string(got) != want {
+ t.Fatalf("%v: %q", err, got)
+ }
+ twice, err := SortKeys(got)
+ if err != nil || string(twice) != want {
+ t.Fatal("sort is not idempotent")
+ }
+}
diff --git a/packages/cli/internal/platform/jsonedit/strings.go b/packages/cli/internal/platform/jsonedit/strings.go
new file mode 100644
index 0000000..1eef200
--- /dev/null
+++ b/packages/cli/internal/platform/jsonedit/strings.go
@@ -0,0 +1,106 @@
+package jsonedit
+
+import (
+ "bytes"
+ "encoding/json"
+ "strconv"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+// RewriteStrings edits JSON string tokens (including object keys) without
+// reserializing surrounding objects. Paths use original keys. Duplicate keys,
+// including collisions introduced by a rewrite, are rejected.
+func RewriteStrings(raw []byte, change func(path []string, key bool, value string) (string, error)) ([]byte, error) {
+ if !json.Valid(raw) {
+ return nil, i18n.Errorf("creation.json_invalid", "")
+ }
+ type edit struct {
+ start, end int
+ value []byte
+ }
+ var edits []edit
+ d := json.NewDecoder(bytes.NewReader(raw))
+ d.UseNumber()
+ rewrite := func(p []string, key bool, value string, start, end int) (string, error) {
+ after, err := change(p, key, value)
+ if err != nil {
+ return "", err
+ }
+ if after != value {
+ for start < end && raw[start] != '"' {
+ start++
+ }
+ encoded, err := Marshal(after)
+ if err != nil {
+ return "", err
+ }
+ edits = append(edits, edit{start, end, encoded})
+ }
+ return after, nil
+ }
+ var visit func([]string) error
+ visit = func(p []string) error {
+ start := int(d.InputOffset())
+ tok, err := d.Token()
+ if err != nil {
+ return err
+ }
+ switch value := tok.(type) {
+ case string:
+ _, err = rewrite(p, false, value, start, int(d.InputOffset()))
+ return err
+ case json.Delim:
+ switch value {
+ case '{':
+ seen := map[string]bool{}
+ renamed := map[string]bool{}
+ for d.More() {
+ start := int(d.InputOffset())
+ tok, err := d.Token()
+ if err != nil {
+ return err
+ }
+ key := tok.(string)
+ if seen[key] {
+ return i18n.Errorf("creation.json_duplicate", key)
+ }
+ seen[key] = true
+ child := append(append([]string{}, p...), key)
+ after, err := rewrite(child, true, key, start, int(d.InputOffset()))
+ if err != nil {
+ return err
+ }
+ if renamed[after] {
+ return i18n.Errorf("creation.json_duplicate", after)
+ }
+ renamed[after] = true
+ if err := visit(child); err != nil {
+ return err
+ }
+ }
+ case '[':
+ for i := 0; d.More(); i++ {
+ if err := visit(append(append([]string{}, p...), strconv.Itoa(i))); err != nil {
+ return err
+ }
+ }
+ }
+ _, err = d.Token()
+ return err
+ }
+ return nil
+ }
+ if err := visit(nil); err != nil {
+ return nil, err
+ }
+ var out bytes.Buffer
+ pos := 0
+ for _, edit := range edits {
+ out.Write(raw[pos:edit.start])
+ out.Write(edit.value)
+ pos = edit.end
+ }
+ out.Write(raw[pos:])
+ return out.Bytes(), nil
+}
diff --git a/packages/cli/internal/platform/jsonedit/strings_test.go b/packages/cli/internal/platform/jsonedit/strings_test.go
new file mode 100644
index 0000000..c3c6d27
--- /dev/null
+++ b/packages/cli/internal/platform/jsonedit/strings_test.go
@@ -0,0 +1,39 @@
+package jsonedit
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRewriteStringsPreservesFormattingAndEscapes(t *testing.T) {
+ raw := "{\r\n\t\"name\": \"old\",\r\n\t\"dependencies\": { \"@old/pkg\": \"workspace:*\" },\r\n\t\"scripts\": { \"dev\": \"echo '' && pnpm -F @old/pkg dev\" },\r\n\t\"array\": [\"leave\", 1, true, null, {}]\r\n}\r\n"
+ got, err := RewriteStrings([]byte(raw), func(p []string, key bool, v string) (string, error) {
+ if len(p) == 1 && !key && p[0] == "name" {
+ return "new", nil
+ }
+ if len(p) == 2 && p[0] == "dependencies" && key {
+ return strings.ReplaceAll(v, "@old/", "@new/"), nil
+ }
+ if len(p) == 2 && p[0] == "scripts" && !key {
+ return strings.ReplaceAll(v, "@old/", "@new/"), nil
+ }
+ return v, nil
+ })
+ want := strings.ReplaceAll(strings.Replace(raw, `"name": "old"`, `"name": "new"`, 1), "@old/", "@new/")
+ if err != nil || string(got) != want {
+ t.Fatalf("%v\nwant %q\ngot %q", err, want, got)
+ }
+}
+func TestRewriteStringsRejectsKeyCollisions(t *testing.T) {
+ for _, raw := range []string{`{"old":"a","new":"b"}`, `{"old":1,"old":2}`, `{"nested":{"name":1,"\u006eame":2}}`} {
+ _, err := RewriteStrings([]byte(raw), func(p []string, key bool, v string) (string, error) {
+ if key && v == "old" {
+ return "new", nil
+ }
+ return v, nil
+ })
+ if err == nil {
+ t.Fatal("accepted collision:", raw)
+ }
+ }
+}
diff --git a/packages/cli/internal/platform/templatefiles/policy.go b/packages/cli/internal/platform/templatefiles/policy.go
new file mode 100644
index 0000000..bf9df69
--- /dev/null
+++ b/packages/cli/internal/platform/templatefiles/policy.go
@@ -0,0 +1,36 @@
+// Package templatefiles defines the shared, runtime-independent bundle layout.
+// Keep this package standard-library-only: the resource builder imports it.
+package templatefiles
+
+import (
+ "path"
+ "strings"
+)
+
+const PackedGoMod = "_go.mod"
+
+// Excluded applies to packaging and generation, including local build artifacts.
+func Excluded(name string) bool {
+ for _, part := range strings.Split(name, "/") {
+ switch part {
+ case ".git", ".one", ".agents", "AGENTS.md", "CLAUDE.md", "SKILL.md",
+ "node_modules", "dist", "build", "bin", "coverage", ".next", ".astro", ".expo", ".turbo", ".cache",
+ "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "bun.lock", "bun.lockb",
+ "go.work", "go.work.sum", "coverage.out", ".DS_Store":
+ return true
+ }
+ }
+ return strings.HasSuffix(name, ".tsbuildinfo")
+}
+func PackPath(name string) string {
+ if path.Base(name) == "go.mod" {
+ return path.Join(path.Dir(name), PackedGoMod)
+ }
+ return name
+}
+func LogicalPath(name string) string {
+ if path.Base(name) == PackedGoMod {
+ return path.Join(path.Dir(name), "go.mod")
+ }
+ return name
+}
diff --git a/packages/cli/internal/resources/bundled/bundled.go b/packages/cli/internal/resources/bundled/bundled.go
index 86a2858..8f82f1e 100644
--- a/packages/cli/internal/resources/bundled/bundled.go
+++ b/packages/cli/internal/resources/bundled/bundled.go
@@ -36,7 +36,8 @@ var RegistryBytes []byte
// TemplatesFS is the bundled templates tree consumed by `one add` when the
// registry entry uses the local: prefix. internal/core/template walks this fs
-// and renders handlebars files into the user's workspace.
+// and copies runnable starter projects into the user's workspace. Go module
+// files travel as _go.mod so embed does not cross a nested module boundary.
//
// The directory is named "_templates" (leading underscore) so the Go toolchain
// skips it during `go build ./...` / `go test ./...` — the literal *.go files
diff --git a/packages/cli/internal/resources/bundled/bundled_test.go b/packages/cli/internal/resources/bundled/bundled_test.go
index 34609c8..d938490 100644
--- a/packages/cli/internal/resources/bundled/bundled_test.go
+++ b/packages/cli/internal/resources/bundled/bundled_test.go
@@ -15,6 +15,7 @@ package bundled_test
// run `mise run sync-bundled` first.
import (
+ "bytes"
"encoding/json"
"io/fs"
"os"
@@ -23,6 +24,7 @@ import (
"strings"
"testing"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/templatefiles"
"github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled"
)
@@ -108,16 +110,23 @@ func TestTemplatesFS_MatchesOnDisk(t *testing.T) {
t.Errorf("retired agent asset embedded in binary: %s", name)
}
}
- // mise run sync-bundled strips go.mod from the bundled copy (a quirk of
- // keeping each Go template module-isolated during repo dev). Apply
- // the same filter to the on-disk side before comparing.
- disk := mustWalkOSFiltered(t, filepath.Join(repoRoot(t), "packages", "templates"), func(rel string) bool {
- // mise run sync-bundled strips go.mod (each Go template is module-isolated
- // during dev) and skips the registry.json sibling (bundled separately
- // at internal/resources/bundled/registry.json).
- base := filepath.Base(rel)
- return base == "go.mod" || rel == "registry.json" || isAgentAsset(rel)
+ diskRoot := filepath.Join(repoRoot(t), "packages", "templates")
+ disk := mustWalkOSFiltered(t, diskRoot, func(rel string) bool {
+ return rel == "registry.json" || templatefiles.Excluded(rel)
})
+ for i, name := range disk {
+ packed := templatefiles.PackPath(name)
+ original, err := os.ReadFile(filepath.Join(diskRoot, filepath.FromSlash(name)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ actual, err := fs.ReadFile(bundled.TemplatesFS, bundled.TemplatesRoot+"/"+packed)
+ if err != nil || !bytes.Equal(actual, original) {
+ t.Errorf("bundled content differs for %s: %v", name, err)
+ }
+ disk[i] = packed
+ }
+
assertSameFiles(t, "templates", embedded, disk)
// At least one known template must ship.
@@ -128,7 +137,7 @@ func TestTemplatesFS_MatchesOnDisk(t *testing.T) {
func isAgentAsset(path string) bool {
for _, part := range strings.Split(filepath.ToSlash(path), "/") {
- switch strings.TrimSuffix(part, ".hbs") {
+ switch part {
case ".one", ".agents", "AGENTS.md", "CLAUDE.md", "SKILL.md":
return true
}
@@ -165,10 +174,13 @@ func mustWalkOSFiltered(t *testing.T, root string, skip func(rel string) bool) [
if err != nil {
return err
}
+ rel, _ := filepath.Rel(root, path)
if d.IsDir() {
+ if skip != nil && skip(filepath.ToSlash(rel)) {
+ return filepath.SkipDir
+ }
return nil
}
- rel, _ := filepath.Rel(root, path)
// Normalise to forward slashes — embed.FS always uses '/'.
rel = filepath.ToSlash(rel)
if skip != nil && skip(rel) {
diff --git a/packages/cli/tools/sync-resources/main.go b/packages/cli/tools/sync-resources/main.go
index ef1d908..c3088aa 100644
--- a/packages/cli/tools/sync-resources/main.go
+++ b/packages/cli/tools/sync-resources/main.go
@@ -11,6 +11,8 @@ import (
"os"
"path/filepath"
"strings"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/templatefiles"
)
func main() {
@@ -65,20 +67,36 @@ func syncBundled(root string) error {
if err := copyFile(filepath.Join(templates, "registry.json"), filepath.Join(bundled, "registry.json")); err != nil {
return err
}
- if err := replaceDir(root, templates, filepath.Join(bundled, "_templates"), func(rel string, entry fs.DirEntry) bool {
- if rel == "registry.json" {
- return false
+ if err := filepath.WalkDir(templates, func(name string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(templates, name)
+ if err != nil {
+ return err
+ }
+ if templatefiles.Excluded(filepath.ToSlash(rel)) {
+ if entry.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
}
- // Workspace guidance is generated by creation, not copied from templates.
- switch strings.TrimSuffix(entry.Name(), ".hbs") {
- case ".one", ".agents", "AGENTS.md", "CLAUDE.md", "SKILL.md",
- "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "bun.lock", "bun.lockb":
- return false
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("template symlinks are not supported: %s", name)
}
- return entry.Name() != "go.mod"
+ if entry.Name() == templatefiles.PackedGoMod || strings.HasSuffix(entry.Name(), ".hbs") {
+ return fmt.Errorf("reserved template filename: %s", name)
+ }
+ return nil
}); err != nil {
return err
}
+ if err := replaceDir(root, templates, filepath.Join(bundled, "_templates"), func(rel string, entry fs.DirEntry) bool {
+ return rel != "registry.json" && !templatefiles.Excluded(filepath.ToSlash(rel))
+ }, true); err != nil {
+ return err
+ }
+
return nil
}
@@ -88,7 +106,7 @@ func syncWeb(root string) error {
if _, err := os.Stat(filepath.Join(source, "index.html")); err != nil {
return fmt.Errorf("dashboard build is missing: %w", err)
}
- return replaceDir(root, source, target, nil)
+ return replaceDir(root, source, target, nil, false)
}
// ensureGeneratedTarget prevents a future path typo from turning RemoveAll
@@ -110,17 +128,17 @@ func ensureGeneratedTarget(root, target string) error {
return nil
}
-func replaceDir(root, source, target string, include func(string, fs.DirEntry) bool) error {
+func replaceDir(root, source, target string, include func(string, fs.DirEntry) bool, pack bool) error {
if err := ensureGeneratedTarget(root, target); err != nil {
return err
}
if err := os.RemoveAll(target); err != nil {
return err
}
- return copyTree(source, target, include)
+ return copyTree(source, target, include, pack)
}
-func copyTree(source, target string, include func(string, fs.DirEntry) bool) error {
+func copyTree(source, target string, include func(string, fs.DirEntry) bool, pack bool) error {
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
@@ -137,6 +155,9 @@ func copyTree(source, target string, include func(string, fs.DirEntry) bool) err
}
dest := target
if rel != "." {
+ if pack {
+ rel = filepath.FromSlash(templatefiles.PackPath(filepath.ToSlash(rel)))
+ }
dest = filepath.Join(target, rel)
}
info, err := entry.Info()
diff --git a/packages/cli/tools/sync-resources/main_test.go b/packages/cli/tools/sync-resources/main_test.go
index 2fdd7a5..6a20ad9 100644
--- a/packages/cli/tools/sync-resources/main_test.go
+++ b/packages/cli/tools/sync-resources/main_test.go
@@ -6,17 +6,18 @@ import (
"testing"
)
-func TestSyncBundledCopiesCanonicalAssetsAndStripsNestedModules(t *testing.T) {
+func TestSyncBundledCopiesCanonicalAssetsAndMapsModuleFiles(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "packages/templates/registry.json", "{}")
writeTestFile(t, root, "packages/templates/go-api/go.mod", "module example")
writeTestFile(t, root, "packages/templates/go-api/main.go", "package main")
retiredAssets := []string{
- "AGENTS.md", "CLAUDE.md", "SKILL.md", "CLAUDE.md.hbs",
+ "AGENTS.md", "CLAUDE.md", "SKILL.md",
".one/agents/conventions.md", ".agents/skills/example/references/guide.md",
- "nested/AGENTS.md.hbs",
- "pnpm-lock.yaml", "nested/package-lock.json", "yarn.lock.hbs",
+ "nested/AGENTS.md",
+ "pnpm-lock.yaml", "nested/package-lock.json", "yarn.lock",
"bun.lock", "bun.lockb", "npm-shrinkwrap.json",
+ "node_modules/p/a.js", "dist/app.js", "apps/ui/build/app.js", "go.work", "go.work.sum", "tsconfig.tsbuildinfo",
}
for _, rel := range retiredAssets {
writeTestFile(t, root, "packages/templates/go-api/"+rel, "retired agent guidance")
@@ -29,7 +30,7 @@ func TestSyncBundledCopiesCanonicalAssetsAndStripsNestedModules(t *testing.T) {
t.Fatalf("syncBundled: %v", err)
}
bundled := filepath.Join(root, "packages", "cli", "internal", "resources", "bundled")
- for _, rel := range []string{"registry.json", "_templates/go-api/main.go"} {
+ for _, rel := range []string{"registry.json", "_templates/go-api/main.go", "_templates/go-api/_go.mod"} {
if _, err := os.Stat(filepath.Join(bundled, filepath.FromSlash(rel))); err != nil {
t.Errorf("expected %s: %v", rel, err)
}
@@ -70,3 +71,21 @@ func writeTestFile(t *testing.T, root, rel, content string) {
t.Fatal(err)
}
}
+
+func TestSyncBundledRejectsReservedNamesBeforeReplacingTemplates(t *testing.T) {
+ for _, bad := range []string{"_go.mod", "main.go.hbs"} {
+ t.Run(bad, func(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "packages/templates/registry.json", "{}")
+ writeTestFile(t, root, "packages/templates/go-api/"+bad, "invalid")
+ writeTestFile(t, root, "packages/cli/internal/resources/bundled/_templates/keep", "previous bundle")
+ if err := syncBundled(root); err == nil {
+ t.Fatal("reserved source name accepted")
+ }
+ data, err := os.ReadFile(filepath.Join(root, "packages/cli/internal/resources/bundled/_templates/keep"))
+ if err != nil || string(data) != "previous bundle" {
+ t.Fatal("replaced previous bundle on validation failure")
+ }
+ })
+ }
+}
diff --git a/packages/cli/tools/verify-cli-references/main.go b/packages/cli/tools/verify-cli-references/main.go
index ed9c730..c0ad5c7 100644
--- a/packages/cli/tools/verify-cli-references/main.go
+++ b/packages/cli/tools/verify-cli-references/main.go
@@ -12,7 +12,7 @@
// What we scan:
// - Top-level: README.md, CONTRIBUTING.md
// - apps/docs/content/docs/**/*.{md,mdx}
-// - packages/templates//README.md and README.md.hbs
+// - packages/templates//README.md
//
// Where we look (intentional):
// - Inside `inline code spans` and ```fenced code blocks```.
@@ -156,7 +156,6 @@ func docFiles() ([]string, error) {
for _, glob := range []string{
filepath.Join(repoRel("packages", "templates"), "*", "README.md"),
- filepath.Join(repoRel("packages", "templates"), "*", "README.md.hbs"),
} {
matches, err := filepath.Glob(glob)
if err != nil {
diff --git a/packages/templates/astro-site/package.json b/packages/templates/astro-site/package.json
index 2273256..4c27a4a 100644
--- a/packages/templates/astro-site/package.json
+++ b/packages/templates/astro-site/package.json
@@ -38,5 +38,5 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4"
+ "packageManager": "pnpm@10.14.0"
}
diff --git a/packages/templates/electron-app/README.md.hbs b/packages/templates/electron-app/README.md
similarity index 94%
rename from packages/templates/electron-app/README.md.hbs
rename to packages/templates/electron-app/README.md
index f10cee2..72008bf 100644
--- a/packages/templates/electron-app/README.md.hbs
+++ b/packages/templates/electron-app/README.md
@@ -1,4 +1,4 @@
-# {{projectName}}
+# OneTemplateElectron
Electron + React + Vite,包含主进程、渲染进程和 preload 三个内部包。
Electron + React + Vite, with separate main, renderer, and preload packages.
@@ -6,11 +6,11 @@ Electron + React + Vite, with separate main, renderer, and preload packages.
## 工作区 / Workspace
这是一个 One 项目,内部包共享仓库根目录的 `pnpm-workspace.yaml` 和
-`pnpm-lock.yaml`。包名使用 `@{{projectNameKebabCase}}/` 前缀,支持在一个仓库中添加多个桌面应用。
+`pnpm-lock.yaml`。包名使用 `@one-template-electron/` 前缀,支持在一个仓库中添加多个桌面应用。
This is one One project. Its internal packages share the repository's root
`pnpm-workspace.yaml` and `pnpm-lock.yaml`. Package names use the
-`@{{projectNameKebabCase}}/` scope so multiple desktop apps can coexist.
+`@one-template-electron/` scope so multiple desktop apps can coexist.
```text
apps/electron/ Electron main process
@@ -26,8 +26,8 @@ Requires a pnpm workspace. Node.js and pnpm versions follow the root configurati
从仓库根目录运行 / Run from the repository root:
```sh
-one dev -p {{projectName}}
-one build -p {{projectName}}
+one dev -p OneTemplateElectron
+one build -p OneTemplateElectron
```
开发时会按需更新根锁文件;请将其提交 Git。已有锁文件在构建时严格校验。
@@ -96,5 +96,6 @@ The template keeps Chromium sandboxing enabled. `--no-sandbox` is for temporary
and disables sandboxing for all processes; keep it out of normal startup and release configuration.
参考 / References:
+
- [Ubuntu: user namespace restrictions](https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890)
- [Electron: process sandboxing](https://www.electronjs.org/docs/latest/tutorial/sandbox)
diff --git a/packages/templates/electron-app/apps/electron/package.json.hbs b/packages/templates/electron-app/apps/electron/package.json
similarity index 91%
rename from packages/templates/electron-app/apps/electron/package.json.hbs
rename to packages/templates/electron-app/apps/electron/package.json
index 515f02c..2f20d79 100644
--- a/packages/templates/electron-app/apps/electron/package.json.hbs
+++ b/packages/templates/electron-app/apps/electron/package.json
@@ -1,5 +1,5 @@
{
- "name": "@{{projectNameKebabCase}}/electron",
+ "name": "@one-template-electron/electron",
"version": "0.1.0",
"description": "electron-template electron main process",
"license": "MIT",
@@ -14,8 +14,8 @@
"release": "cross-env NODE_ENV=production node script/release.mjs"
},
"dependencies": {
- "@{{projectNameKebabCase}}/preload": "workspace:*",
"@inversifyjs/binding-decorators": "^3.0.0",
+ "@one-template-electron/preload": "workspace:*",
"dayjs": "^1.11.23",
"electron-devtools-installer": "^4.0.0",
"electron-is-dev": "^3.0.1",
diff --git a/packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs b/packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs
index e8c3d65..a56a582 100644
--- a/packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs
+++ b/packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs
@@ -18,7 +18,10 @@ profile ${name} ${quoted} flags=(unconfined) {
`;
}
-if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+if (
+ process.argv[1] &&
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
const require = createRequire(import.meta.url);
process.stdout.write(sandboxProfile(require("electron")));
}
diff --git a/packages/templates/electron-app/apps/electron/src/types/events.ts.hbs b/packages/templates/electron-app/apps/electron/src/types/events.ts
similarity index 78%
rename from packages/templates/electron-app/apps/electron/src/types/events.ts.hbs
rename to packages/templates/electron-app/apps/electron/src/types/events.ts
index 006e280..dff63b3 100644
--- a/packages/templates/electron-app/apps/electron/src/types/events.ts.hbs
+++ b/packages/templates/electron-app/apps/electron/src/types/events.ts
@@ -3,7 +3,7 @@ export const IPC_EVENT_METADATA = Symbol.for("app:ipc-event");
export const IPC_METHOD_METADATA = Symbol.for("app:ipc-method");
// IPC 通道常量从 preload 包 re-export —— 主进程和 renderer 共用同一份源
-export { IPC, EVENT } from "@{{projectNameKebabCase}}/preload/channels";
+export { IPC, EVENT } from "@one-template-electron/preload/channels";
export type {
IpcResponse,
InvokeMap,
@@ -12,4 +12,4 @@ export type {
DialogOpenOptions,
DialogSaveOptions,
ContextMenuItem,
-} from "@{{projectNameKebabCase}}/preload/channels";
+} from "@one-template-electron/preload/channels";
diff --git a/packages/templates/electron-app/apps/electron/src/utils/index.ts.hbs b/packages/templates/electron-app/apps/electron/src/utils/index.ts
similarity index 80%
rename from packages/templates/electron-app/apps/electron/src/utils/index.ts.hbs
rename to packages/templates/electron-app/apps/electron/src/utils/index.ts
index 6e39d82..e15c7b5 100644
--- a/packages/templates/electron-app/apps/electron/src/utils/index.ts.hbs
+++ b/packages/templates/electron-app/apps/electron/src/utils/index.ts
@@ -23,7 +23,8 @@ export const defaultScheme = DEFAULT_SCHEME;
export const noop = () => {};
// preload 脚本绝对路径 —— 主窗口 webPreferences.preload 要指向这里
-// @{{projectNameKebabCase}}/preload 包的 main 指向 dist/index.cjs,这里 resolve 出真实路径
-export const preloadPath = requireFromHere.resolve("@{{projectNameKebabCase}}/preload");
+// @one-template-electron/preload 包的 main 指向 dist/index.cjs,这里 resolve 出真实路径
+const preloadPackage = "@one-template-electron/preload";
+export const preloadPath = requireFromHere.resolve(preloadPackage);
export { __dirname, __filename };
diff --git a/packages/templates/electron-app/apps/ui/package.json.hbs b/packages/templates/electron-app/apps/ui/package.json
similarity index 90%
rename from packages/templates/electron-app/apps/ui/package.json.hbs
rename to packages/templates/electron-app/apps/ui/package.json
index 8123828..86a9355 100644
--- a/packages/templates/electron-app/apps/ui/package.json.hbs
+++ b/packages/templates/electron-app/apps/ui/package.json
@@ -1,5 +1,5 @@
{
- "name": "@{{projectNameKebabCase}}/ui",
+ "name": "@one-template-electron/ui",
"version": "0.1.0",
"private": true,
"description": "electron-template ui (renderer)",
@@ -11,7 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@{{projectNameKebabCase}}/preload": "workspace:*",
+ "@one-template-electron/preload": "workspace:*",
"@radix-ui/react-slot": "^1.3.3",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
diff --git a/packages/templates/electron-app/apps/ui/src/hooks/electron.ts.hbs b/packages/templates/electron-app/apps/ui/src/hooks/electron.ts
similarity index 74%
rename from packages/templates/electron-app/apps/ui/src/hooks/electron.ts.hbs
rename to packages/templates/electron-app/apps/ui/src/hooks/electron.ts
index 4960e2f..1a3219e 100644
--- a/packages/templates/electron-app/apps/ui/src/hooks/electron.ts.hbs
+++ b/packages/templates/electron-app/apps/ui/src/hooks/electron.ts
@@ -1,4 +1,4 @@
-import type { ElectronAPI } from "@{{projectNameKebabCase}}/preload";
+import type { ElectronAPI } from "@one-template-electron/preload";
// 直接透传 preload 暴露的 API
// preload 内部已解包信封并在失败时抛错,这里不再重复包装
diff --git a/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx.hbs b/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx
similarity index 96%
rename from packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx.hbs
rename to packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx
index 42c7754..25449ce 100644
--- a/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx.hbs
+++ b/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx
@@ -3,9 +3,9 @@
*
* Pre-wired infrastructure (don't recreate; import from these paths):
* - IPC client: useElectron() in src/hooks/electron.ts
- * (electron.invoke / .send / .on; types from @{{projectNameKebabCase}}/preload/channels)
+ * (electron.invoke / .send / .on; types from @one-template-electron/preload/channels)
* - IPC channels: IPC.dialog.open / IPC.shell.open / IPC.contextMenu.show, etc.
- * (full list in @{{projectNameKebabCase}}/preload/channels — DO NOT redefine)
+ * (full list in @one-template-electron/preload/channels — DO NOT redefine)
* - IPC events: EVENT.update.downloadProgress, etc. — subscribe via electron.on()
* - Store: useAppStore (zustand + immer) in src/store/app-store.ts
* - HTTP client: src/lib/http.ts (axios fetcher, e.g. for SWR with public APIs)
diff --git a/packages/templates/electron-app/apps/ui/src/renderer.d.ts b/packages/templates/electron-app/apps/ui/src/renderer.d.ts
new file mode 100644
index 0000000..1f0aebf
--- /dev/null
+++ b/packages/templates/electron-app/apps/ui/src/renderer.d.ts
@@ -0,0 +1,2 @@
+// 把 @one-template-electron/preload 暴露的 ElectronAPI 注入到 window.electron 的类型上
+import "@one-template-electron/preload";
diff --git a/packages/templates/electron-app/apps/ui/src/renderer.d.ts.hbs b/packages/templates/electron-app/apps/ui/src/renderer.d.ts.hbs
deleted file mode 100644
index 944034a..0000000
--- a/packages/templates/electron-app/apps/ui/src/renderer.d.ts.hbs
+++ /dev/null
@@ -1,2 +0,0 @@
-// 把 @{{projectNameKebabCase}}/preload 暴露的 ElectronAPI 注入到 window.electron 的类型上
-import "@{{projectNameKebabCase}}/preload";
diff --git a/packages/templates/electron-app/package.json b/packages/templates/electron-app/package.json
new file mode 100644
index 0000000..c9e94d4
--- /dev/null
+++ b/packages/templates/electron-app/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "one-template-electron",
+ "version": "0.1.0",
+ "private": true,
+ "description": "electron-template",
+ "keywords": [],
+ "license": "MIT",
+ "author": "",
+ "workspaces": [
+ "apps/electron",
+ "apps/ui",
+ "packages/preload"
+ ],
+ "scripts": {
+ "dev": "pnpm -F @one-template-electron/preload build && pnpm -r --parallel -F @one-template-electron/electron -F @one-template-electron/ui run dev",
+ "build": "pnpm -F @one-template-electron/preload run build && pnpm -r --parallel -F @one-template-electron/electron -F @one-template-electron/ui run build",
+ "pack": "pnpm -F @one-template-electron/electron run pack",
+ "sandbox:profile": "pnpm --silent -F @one-template-electron/electron run sandbox:profile",
+ "release": "pnpm run build && pnpm -F @one-template-electron/electron run release",
+ "lint": "oxlint",
+ "lint:fix": "oxlint --fix",
+ "format": "oxfmt",
+ "format:check": "oxfmt --check"
+ },
+ "devDependencies": {
+ "oxfmt": "^0.67.0",
+ "oxlint": "^1.82.0"
+ },
+ "engines": {
+ "node": "^24.15.0 || >=26.0.0"
+ },
+ "packageManager": "pnpm@10.14.0"
+}
diff --git a/packages/templates/electron-app/package.json.hbs b/packages/templates/electron-app/package.json.hbs
deleted file mode 100644
index fbb984c..0000000
--- a/packages/templates/electron-app/package.json.hbs
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "name": "{{projectNameKebabCase}}",
- "version": "0.1.0",
- "private": true,
- "description": "electron-template",
- "keywords": [],
- "license": "MIT",
- "author": "",
- "scripts": {
- "dev": "pnpm -F @{{projectNameKebabCase}}/preload build && pnpm -r --parallel -F @{{projectNameKebabCase}}/electron -F @{{projectNameKebabCase}}/ui run dev",
- "build": "pnpm -F @{{projectNameKebabCase}}/preload run build && pnpm -r --parallel -F @{{projectNameKebabCase}}/electron -F @{{projectNameKebabCase}}/ui run build",
- "pack": "pnpm -F @{{projectNameKebabCase}}/electron run pack",
- "sandbox:profile": "pnpm --silent -F @{{projectNameKebabCase}}/electron run sandbox:profile",
- "release": "pnpm run build && pnpm -F @{{projectNameKebabCase}}/electron run release",
- "lint": "oxlint",
- "lint:fix": "oxlint --fix",
- "format": "oxfmt",
- "format:check": "oxfmt --check"
- },
- "devDependencies": {
- "oxfmt": "^0.67.0",
- "oxlint": "^1.82.0"
- },
- "engines": {
- "node": "^24.15.0 || >=26.0.0"
- },
- "packageManager": "pnpm@12.3.4",
- "workspaces": [
- "apps/electron",
- "apps/ui",
- "packages/preload"
- ]
-}
diff --git a/packages/templates/electron-app/packages/preload/package.json.hbs b/packages/templates/electron-app/packages/preload/package.json
similarity index 92%
rename from packages/templates/electron-app/packages/preload/package.json.hbs
rename to packages/templates/electron-app/packages/preload/package.json
index 49d0abb..9f166c5 100644
--- a/packages/templates/electron-app/packages/preload/package.json.hbs
+++ b/packages/templates/electron-app/packages/preload/package.json
@@ -1,5 +1,5 @@
{
- "name": "@{{projectNameKebabCase}}/preload",
+ "name": "@one-template-electron/preload",
"version": "0.1.0",
"private": true,
"files": [
diff --git a/packages/templates/electron-app/pnpm-workspace.yaml b/packages/templates/electron-app/pnpm-workspace.yaml
new file mode 100644
index 0000000..e81c27d
--- /dev/null
+++ b/packages/templates/electron-app/pnpm-workspace.yaml
@@ -0,0 +1,7 @@
+packages:
+ - apps/electron
+ - apps/ui
+ - packages/preload
+
+onlyBuiltDependencies:
+ - electron
diff --git a/packages/templates/electron-app/template.json b/packages/templates/electron-app/template.json
new file mode 100644
index 0000000..a623aa1
--- /dev/null
+++ b/packages/templates/electron-app/template.json
@@ -0,0 +1,26 @@
+{
+ "schemaVersion": 1,
+ "node": {
+ "scope": "@one-template-electron",
+ "sourceFiles": [
+ "apps/electron/src/utils/index.ts",
+ "apps/electron/src/types/events.ts",
+ "apps/ui/src/nodes/HomePage/index.tsx",
+ "apps/ui/src/renderer.d.ts",
+ "apps/ui/src/hooks/electron.ts"
+ ]
+ },
+ "text": [
+ {
+ "files": ["README.md"],
+ "from": "OneTemplateElectron",
+ "value": "projectName"
+ },
+ {
+ "files": ["README.md"],
+ "from": "one-template-electron",
+ "value": "projectNameKebabCase"
+ }
+ ],
+ "exclude": ["pnpm-workspace.yaml"]
+}
diff --git a/packages/templates/expo-mobile/package.json b/packages/templates/expo-mobile/package.json
index f59ea59..159c550 100644
--- a/packages/templates/expo-mobile/package.json
+++ b/packages/templates/expo-mobile/package.json
@@ -95,7 +95,7 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4",
+ "packageManager": "pnpm@10.14.0",
"expo": {
"install": {
"exclude": [
diff --git a/packages/templates/go-api/README.md.hbs b/packages/templates/go-api/README.md
similarity index 98%
rename from packages/templates/go-api/README.md.hbs
rename to packages/templates/go-api/README.md
index 194bc2d..809d412 100644
--- a/packages/templates/go-api/README.md.hbs
+++ b/packages/templates/go-api/README.md
@@ -1,4 +1,4 @@
-# {{projectNameKebabCase}}
+# one-template-go-api
Gin + Gorm + Viper + Zap API service generated by One CLI.
diff --git a/packages/templates/go-api/cmd/server/main.go.hbs b/packages/templates/go-api/cmd/server/main.go
similarity index 84%
rename from packages/templates/go-api/cmd/server/main.go.hbs
rename to packages/templates/go-api/cmd/server/main.go
index 6e82546..72fdafd 100644
--- a/packages/templates/go-api/cmd/server/main.go.hbs
+++ b/packages/templates/go-api/cmd/server/main.go
@@ -11,9 +11,9 @@ import (
"go.uber.org/zap"
- "github.com/example/{{projectNameKebabCase}}/internal/app"
- "github.com/example/{{projectNameKebabCase}}/internal/config"
- "github.com/example/{{projectNameKebabCase}}/internal/platform/logger"
+ "github.com/example/one-template-go-api/internal/app"
+ "github.com/example/one-template-go-api/internal/config"
+ "github.com/example/one-template-go-api/internal/platform/logger"
)
func main() {
diff --git a/packages/templates/go-api/go.mod b/packages/templates/go-api/go.mod
index b9748b9..3dbf7bf 100644
--- a/packages/templates/go-api/go.mod
+++ b/packages/templates/go-api/go.mod
@@ -1,9 +1,4 @@
-// Dev-only module declaration. This file isolates the go-api template's
-// literal *.go files from parent Go modules during repository development.
-// The template renderer excludes this file; generated projects use go.mod.hbs.
-// Keep requirements synchronized with go.mod.hbs using a rendered project:
-// go mod tidy here cannot see the imports in *.go.hbs template files.
-module template-api-go
+module github.com/example/one-template-go-api
go 1.27.0
diff --git a/packages/templates/go-api/go.mod.hbs b/packages/templates/go-api/go.mod.hbs
deleted file mode 100644
index a6ae151..0000000
--- a/packages/templates/go-api/go.mod.hbs
+++ /dev/null
@@ -1,93 +0,0 @@
-module github.com/example/{{projectNameKebabCase}}
-
-go 1.27.0
-
-require (
- github.com/gin-gonic/gin v1.12.0
- github.com/glebarez/sqlite v1.11.0
- github.com/golang-jwt/jwt/v5 v5.3.1
- github.com/google/uuid v1.6.0
- github.com/prometheus/client_golang v1.24.1
- github.com/spf13/viper v1.21.0
- github.com/swaggo/files v1.0.1
- github.com/swaggo/gin-swagger v1.6.1
- go.uber.org/zap v1.28.0
- golang.org/x/crypto v0.57.0
- gorm.io/driver/postgres v1.6.2
- gorm.io/gorm v1.31.2
-)
-
-require (
- github.com/KyleBanks/depth v1.2.1 // indirect
- github.com/beorn7/perks v1.0.1 // indirect
- github.com/bytedance/gopkg v0.1.4 // indirect
- github.com/bytedance/sonic v1.15.3 // indirect
- github.com/bytedance/sonic/loader v0.5.2 // indirect
- github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/cloudwego/base64x v0.1.7 // indirect
- github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/fsnotify/fsnotify v1.10.1 // indirect
- github.com/gabriel-vasile/mimetype v1.4.15 // indirect
- github.com/gin-contrib/sse v1.1.2 // indirect
- github.com/glebarez/go-sqlite v1.23.0 // indirect
- github.com/go-openapi/jsonpointer v1.0.1 // indirect
- github.com/go-openapi/jsonreference v1.0.2 // indirect
- github.com/go-openapi/spec v1.0.1 // indirect
- github.com/go-openapi/swag/conv v0.29.2 // indirect
- github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
- github.com/go-openapi/swag/loading v0.29.2 // indirect
- github.com/go-openapi/swag/pools v0.29.2 // indirect
- github.com/go-openapi/swag/stringutils v0.29.2 // indirect
- github.com/go-openapi/swag/typeutils v0.29.2 // indirect
- github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
- github.com/go-playground/locales v0.14.1 // indirect
- github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.30.4 // indirect
- github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
- github.com/goccy/go-json v0.10.6 // indirect
- github.com/goccy/go-yaml v1.19.2 // indirect
- github.com/jackc/pgpassfile v1.0.0 // indirect
- github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/pgx/v5 v5.11.0 // indirect
- github.com/jackc/puddle/v2 v2.2.2 // indirect
- github.com/jinzhu/inflection v1.0.0 // indirect
- github.com/jinzhu/now v1.1.5 // indirect
- github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/cpuid/v2 v2.4.0 // indirect
- github.com/leodido/go-urn v1.5.0 // indirect
- github.com/mattn/go-isatty v0.0.24 // indirect
- github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
- github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/pelletier/go-toml/v2 v2.4.3 // indirect
- github.com/prometheus/client_model v0.6.3 // indirect
- github.com/prometheus/common v0.71.0 // indirect
- github.com/prometheus/procfs v0.22.0 // indirect
- github.com/quic-go/qpack v0.6.0 // indirect
- github.com/quic-go/quic-go v0.62.0 // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/sagikazarmark/locafero v0.12.0 // indirect
- github.com/spf13/afero v1.15.0 // indirect
- github.com/spf13/cast v1.10.0 // indirect
- github.com/spf13/pflag v1.0.10 // indirect
- github.com/subosito/gotenv v1.6.0 // indirect
- github.com/swaggo/swag v1.16.6 // indirect
- github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
- github.com/ugorji/go/codec v1.3.2 // indirect
- go.mongodb.org/mongo-driver/v2 v2.9.0 // indirect
- go.uber.org/multierr v1.11.0 // indirect
- go.yaml.in/yaml/v3 v3.0.5 // indirect
- golang.org/x/arch v0.31.0 // indirect
- golang.org/x/mod v0.41.0 // indirect
- golang.org/x/net v0.59.0 // indirect
- golang.org/x/sync v0.23.0 // indirect
- golang.org/x/sys v0.48.0 // indirect
- golang.org/x/text v0.42.0 // indirect
- golang.org/x/tools v0.50.0 // indirect
- google.golang.org/protobuf v1.36.12 // indirect
- modernc.org/libc v1.75.7 // indirect
- modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.12.1 // indirect
- modernc.org/sqlite v1.58.0 // indirect
-)
diff --git a/packages/templates/go-api/go.sum.hbs b/packages/templates/go-api/go.sum.hbs
deleted file mode 100644
index 5a83dcf..0000000
--- a/packages/templates/go-api/go.sum.hbs
+++ /dev/null
@@ -1,277 +0,0 @@
-github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
-github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
-github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
-github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
-github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
-github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
-github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
-github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
-github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
-github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
-github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
-github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
-github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
-github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
-github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
-github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
-github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
-github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
-github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
-github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
-github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
-github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
-github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
-github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
-github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
-github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
-github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
-github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
-github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
-github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
-github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
-github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
-github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
-github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
-github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
-github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
-github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
-github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
-github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
-github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
-github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
-github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
-github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
-github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
-github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
-github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
-github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
-github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
-github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
-github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
-github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
-github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
-github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
-github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
-github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
-github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
-github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
-github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
-github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
-github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
-github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
-github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
-github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
-github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
-github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
-github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
-github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
-github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
-github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
-github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
-github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
-github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
-github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
-github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
-github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
-github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
-github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
-github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
-github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
-github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
-github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
-github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
-github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
-github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
-github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
-github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
-github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
-github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
-github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
-github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
-github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
-github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
-github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
-github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
-github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
-github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
-github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
-github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
-github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
-github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
-github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
-github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
-github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
-github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
-github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
-github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
-github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
-github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
-go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
-go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
-go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
-go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
-go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
-go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
-go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
-go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
-go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
-golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
-golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
-golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
-golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
-golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
-golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
-golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
-golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
-golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
-google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
-gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
-gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
-gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
-gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
-modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
-modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
-modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
-modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
-modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
-modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
-modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
-modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
-modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
-modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
-modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
-modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
-modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
-modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
-modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
-modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
-modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
-modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
-modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
-modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
-modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/packages/templates/go-api/go.work b/packages/templates/go-api/go.work
new file mode 100644
index 0000000..3915ffc
--- /dev/null
+++ b/packages/templates/go-api/go.work
@@ -0,0 +1,3 @@
+go 1.27.0
+
+use .
diff --git a/packages/templates/go-api/internal/app/app.go.hbs b/packages/templates/go-api/internal/app/app.go
similarity index 67%
rename from packages/templates/go-api/internal/app/app.go.hbs
rename to packages/templates/go-api/internal/app/app.go
index ffdf055..abab9ee 100644
--- a/packages/templates/go-api/internal/app/app.go.hbs
+++ b/packages/templates/go-api/internal/app/app.go
@@ -4,13 +4,13 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
- "github.com/example/{{projectNameKebabCase}}/internal/config"
- apihttp "github.com/example/{{projectNameKebabCase}}/internal/http"
- "github.com/example/{{projectNameKebabCase}}/internal/http/handlers"
- "github.com/example/{{projectNameKebabCase}}/internal/platform/jwt"
- "github.com/example/{{projectNameKebabCase}}/internal/platform/postgres"
- "github.com/example/{{projectNameKebabCase}}/internal/repository"
- "github.com/example/{{projectNameKebabCase}}/internal/service"
+ "github.com/example/one-template-go-api/internal/config"
+ apihttp "github.com/example/one-template-go-api/internal/http"
+ "github.com/example/one-template-go-api/internal/http/handlers"
+ "github.com/example/one-template-go-api/internal/platform/jwt"
+ "github.com/example/one-template-go-api/internal/platform/postgres"
+ "github.com/example/one-template-go-api/internal/repository"
+ "github.com/example/one-template-go-api/internal/service"
)
type App struct {
diff --git a/packages/templates/go-api/internal/http/handlers/app_handler.go.hbs b/packages/templates/go-api/internal/http/handlers/app_handler.go
similarity index 76%
rename from packages/templates/go-api/internal/http/handlers/app_handler.go.hbs
rename to packages/templates/go-api/internal/http/handlers/app_handler.go
index 00863d4..2bb3bba 100644
--- a/packages/templates/go-api/internal/http/handlers/app_handler.go.hbs
+++ b/packages/templates/go-api/internal/http/handlers/app_handler.go
@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin"
- "github.com/example/{{projectNameKebabCase}}/internal/config"
- "github.com/example/{{projectNameKebabCase}}/internal/http/response"
+ "github.com/example/one-template-go-api/internal/config"
+ "github.com/example/one-template-go-api/internal/http/response"
)
type AppHandler struct {
diff --git a/packages/templates/go-api/internal/http/handlers/auth_handler.go.hbs b/packages/templates/go-api/internal/http/handlers/auth_handler.go
similarity index 87%
rename from packages/templates/go-api/internal/http/handlers/auth_handler.go.hbs
rename to packages/templates/go-api/internal/http/handlers/auth_handler.go
index f382bd7..c01dcba 100644
--- a/packages/templates/go-api/internal/http/handlers/auth_handler.go.hbs
+++ b/packages/templates/go-api/internal/http/handlers/auth_handler.go
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin"
- "github.com/example/{{projectNameKebabCase}}/internal/http/response"
- "github.com/example/{{projectNameKebabCase}}/internal/service"
+ "github.com/example/one-template-go-api/internal/http/response"
+ "github.com/example/one-template-go-api/internal/service"
)
type AuthHandler struct {
diff --git a/packages/templates/go-api/internal/http/handlers/health_handler.go.hbs b/packages/templates/go-api/internal/http/handlers/health_handler.go
similarity index 90%
rename from packages/templates/go-api/internal/http/handlers/health_handler.go.hbs
rename to packages/templates/go-api/internal/http/handlers/health_handler.go
index b7ded2e..09bd4fd 100644
--- a/packages/templates/go-api/internal/http/handlers/health_handler.go.hbs
+++ b/packages/templates/go-api/internal/http/handlers/health_handler.go
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
- "github.com/example/{{projectNameKebabCase}}/internal/http/response"
+ "github.com/example/one-template-go-api/internal/http/response"
)
type HealthHandler struct {
diff --git a/packages/templates/go-api/internal/http/handlers/user_handler.go.hbs b/packages/templates/go-api/internal/http/handlers/user_handler.go
similarity index 92%
rename from packages/templates/go-api/internal/http/handlers/user_handler.go.hbs
rename to packages/templates/go-api/internal/http/handlers/user_handler.go
index c6ce4ff..20aca29 100644
--- a/packages/templates/go-api/internal/http/handlers/user_handler.go.hbs
+++ b/packages/templates/go-api/internal/http/handlers/user_handler.go
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
- "github.com/example/{{projectNameKebabCase}}/internal/http/response"
- "github.com/example/{{projectNameKebabCase}}/internal/service"
+ "github.com/example/one-template-go-api/internal/http/response"
+ "github.com/example/one-template-go-api/internal/service"
)
type UserHandler struct {
diff --git a/packages/templates/go-api/internal/http/middleware/auth.go.hbs b/packages/templates/go-api/internal/http/middleware/auth.go
similarity index 85%
rename from packages/templates/go-api/internal/http/middleware/auth.go.hbs
rename to packages/templates/go-api/internal/http/middleware/auth.go
index 1dc5415..5e63166 100644
--- a/packages/templates/go-api/internal/http/middleware/auth.go.hbs
+++ b/packages/templates/go-api/internal/http/middleware/auth.go
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin"
- "github.com/example/{{projectNameKebabCase}}/internal/http/response"
- "github.com/example/{{projectNameKebabCase}}/internal/platform/jwt"
+ "github.com/example/one-template-go-api/internal/http/response"
+ "github.com/example/one-template-go-api/internal/platform/jwt"
)
const CurrentUserIDKey = "current_user_id"
diff --git a/packages/templates/go-api/internal/http/middleware/cors.go.hbs b/packages/templates/go-api/internal/http/middleware/cors.go
similarity index 93%
rename from packages/templates/go-api/internal/http/middleware/cors.go.hbs
rename to packages/templates/go-api/internal/http/middleware/cors.go
index 9719fb5..b5919f8 100644
--- a/packages/templates/go-api/internal/http/middleware/cors.go.hbs
+++ b/packages/templates/go-api/internal/http/middleware/cors.go
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
- "github.com/example/{{projectNameKebabCase}}/internal/config"
+ "github.com/example/one-template-go-api/internal/config"
)
func CORS(cfg config.Config) gin.HandlerFunc {
diff --git a/packages/templates/go-api/internal/http/middleware/recovery.go.hbs b/packages/templates/go-api/internal/http/middleware/recovery.go
similarity index 86%
rename from packages/templates/go-api/internal/http/middleware/recovery.go.hbs
rename to packages/templates/go-api/internal/http/middleware/recovery.go
index e699e1e..5a2daa2 100644
--- a/packages/templates/go-api/internal/http/middleware/recovery.go.hbs
+++ b/packages/templates/go-api/internal/http/middleware/recovery.go
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
- "github.com/example/{{projectNameKebabCase}}/internal/http/response"
+ "github.com/example/one-template-go-api/internal/http/response"
)
func Recovery(log *zap.Logger) gin.HandlerFunc {
diff --git a/packages/templates/go-api/internal/http/router.go.hbs b/packages/templates/go-api/internal/http/router.go
similarity index 82%
rename from packages/templates/go-api/internal/http/router.go.hbs
rename to packages/templates/go-api/internal/http/router.go
index 3a3752c..2de1a3b 100644
--- a/packages/templates/go-api/internal/http/router.go.hbs
+++ b/packages/templates/go-api/internal/http/router.go
@@ -7,10 +7,10 @@ import (
ginSwagger "github.com/swaggo/gin-swagger"
"go.uber.org/zap"
- "github.com/example/{{projectNameKebabCase}}/internal/config"
- "github.com/example/{{projectNameKebabCase}}/internal/http/handlers"
- "github.com/example/{{projectNameKebabCase}}/internal/http/middleware"
- "github.com/example/{{projectNameKebabCase}}/internal/platform/jwt"
+ "github.com/example/one-template-go-api/internal/config"
+ "github.com/example/one-template-go-api/internal/http/handlers"
+ "github.com/example/one-template-go-api/internal/http/middleware"
+ "github.com/example/one-template-go-api/internal/platform/jwt"
)
type Router struct {
diff --git a/packages/templates/go-api/internal/platform/postgres/postgres.go.hbs b/packages/templates/go-api/internal/platform/postgres/postgres.go
similarity index 97%
rename from packages/templates/go-api/internal/platform/postgres/postgres.go.hbs
rename to packages/templates/go-api/internal/platform/postgres/postgres.go
index 9469f33..c3bace4 100644
--- a/packages/templates/go-api/internal/platform/postgres/postgres.go.hbs
+++ b/packages/templates/go-api/internal/platform/postgres/postgres.go
@@ -15,7 +15,7 @@ import (
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
- "github.com/example/{{projectNameKebabCase}}/internal/domain"
+ "github.com/example/one-template-go-api/internal/domain"
)
// Open returns a configured *gorm.DB. When databaseURL is empty, falls
diff --git a/packages/templates/go-api/internal/repository/user_repository.go.hbs b/packages/templates/go-api/internal/repository/user_repository.go
similarity index 95%
rename from packages/templates/go-api/internal/repository/user_repository.go.hbs
rename to packages/templates/go-api/internal/repository/user_repository.go
index 797e04a..1878d42 100644
--- a/packages/templates/go-api/internal/repository/user_repository.go.hbs
+++ b/packages/templates/go-api/internal/repository/user_repository.go
@@ -6,7 +6,7 @@ import (
"gorm.io/gorm"
- "github.com/example/{{projectNameKebabCase}}/internal/domain"
+ "github.com/example/one-template-go-api/internal/domain"
)
var ErrNotFound = errors.New("resource not found")
diff --git a/packages/templates/go-api/internal/service/auth_service.go.hbs b/packages/templates/go-api/internal/service/auth_service.go
similarity index 86%
rename from packages/templates/go-api/internal/service/auth_service.go.hbs
rename to packages/templates/go-api/internal/service/auth_service.go
index 43a7186..b5656d8 100644
--- a/packages/templates/go-api/internal/service/auth_service.go.hbs
+++ b/packages/templates/go-api/internal/service/auth_service.go
@@ -7,9 +7,9 @@ import (
"golang.org/x/crypto/bcrypt"
- "github.com/example/{{projectNameKebabCase}}/internal/domain"
- "github.com/example/{{projectNameKebabCase}}/internal/platform/jwt"
- "github.com/example/{{projectNameKebabCase}}/internal/repository"
+ "github.com/example/one-template-go-api/internal/domain"
+ "github.com/example/one-template-go-api/internal/platform/jwt"
+ "github.com/example/one-template-go-api/internal/repository"
)
type TokenSigner interface {
diff --git a/packages/templates/go-api/internal/service/user_service.go.hbs b/packages/templates/go-api/internal/service/user_service.go
similarity index 93%
rename from packages/templates/go-api/internal/service/user_service.go.hbs
rename to packages/templates/go-api/internal/service/user_service.go
index e248013..e7b7350 100644
--- a/packages/templates/go-api/internal/service/user_service.go.hbs
+++ b/packages/templates/go-api/internal/service/user_service.go
@@ -8,8 +8,8 @@ import (
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
- "github.com/example/{{projectNameKebabCase}}/internal/domain"
- "github.com/example/{{projectNameKebabCase}}/internal/repository"
+ "github.com/example/one-template-go-api/internal/domain"
+ "github.com/example/one-template-go-api/internal/repository"
)
var (
diff --git a/packages/templates/go-api/template.json b/packages/templates/go-api/template.json
new file mode 100644
index 0000000..6dee9d4
--- /dev/null
+++ b/packages/templates/go-api/template.json
@@ -0,0 +1,15 @@
+{
+ "schemaVersion": 1,
+ "go": {
+ "modulePrefix": "github.com/example/"
+ },
+ "text": [
+ {
+ "files": [
+ "README.md"
+ ],
+ "from": "one-template-go-api",
+ "value": "projectNameKebabCase"
+ }
+ ]
+}
diff --git a/packages/templates/go-lib/README.md.hbs b/packages/templates/go-lib/README.md
similarity index 85%
rename from packages/templates/go-lib/README.md.hbs
rename to packages/templates/go-lib/README.md
index b8bbae8..ca4859a 100644
--- a/packages/templates/go-lib/README.md.hbs
+++ b/packages/templates/go-lib/README.md
@@ -1,4 +1,4 @@
-# {{projectNameKebabCase}}
+# one-template-go-lib
Go library generated by One CLI. Layout follows
[golang-standards/project-layout](https://github.com/golang-standards/project-layout).
@@ -29,11 +29,11 @@ packages as the library grows, following the project layout linked above.
## Use as a dependency
```bash
-go get github.com/example/{{projectNameKebabCase}}
+go get github.com/example/one-template-go-lib
```
```go
-import "github.com/example/{{projectNameKebabCase}}/pkg/greeter"
+import "github.com/example/one-template-go-lib/pkg/greeter"
func main() {
fmt.Println(greeter.Greet("world"))
diff --git a/packages/templates/go-lib/go.mod b/packages/templates/go-lib/go.mod
index edc40e6..b118ad3 100644
--- a/packages/templates/go-lib/go.mod
+++ b/packages/templates/go-lib/go.mod
@@ -1,8 +1,3 @@
-// Dev-only module declaration. This file isolates the go-lib template's
-// literal *.go files from the parent one-cli Go module during repository
-// development, so `go build ./...` at the repo root doesn't try to compile
-// these template fragments. The renderer skips this file when materialising
-// the template; the user's actual go.mod is rendered from go.mod.hbs.
-module template-go-lib
+module github.com/example/one-template-go-lib
go 1.27.0
diff --git a/packages/templates/go-lib/go.mod.hbs b/packages/templates/go-lib/go.mod.hbs
deleted file mode 100644
index 7319f0b..0000000
--- a/packages/templates/go-lib/go.mod.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-module github.com/example/{{projectNameKebabCase}}
-
-go 1.27.0
diff --git a/packages/templates/go-lib/go.work b/packages/templates/go-lib/go.work
new file mode 100644
index 0000000..3915ffc
--- /dev/null
+++ b/packages/templates/go-lib/go.work
@@ -0,0 +1,3 @@
+go 1.27.0
+
+use .
diff --git a/packages/templates/go-lib/pkg/greeter/greeter.go.hbs b/packages/templates/go-lib/pkg/greeter/greeter.go
similarity index 88%
rename from packages/templates/go-lib/pkg/greeter/greeter.go.hbs
rename to packages/templates/go-lib/pkg/greeter/greeter.go
index aba94d7..11990c7 100644
--- a/packages/templates/go-lib/pkg/greeter/greeter.go.hbs
+++ b/packages/templates/go-lib/pkg/greeter/greeter.go
@@ -1,5 +1,5 @@
// Package greeter is a placeholder public package for the
-// {{projectNameKebabCase}} library. Replace its contents with the
+// one-template-go-lib library. Replace its contents with the
// real public API of your library. Files placed under pkg/ are part
// of the published surface and follow semantic versioning.
package greeter
diff --git a/packages/templates/go-lib/pkg/greeter/greeter_test.go.hbs b/packages/templates/go-lib/pkg/greeter/greeter_test.go
similarity index 100%
rename from packages/templates/go-lib/pkg/greeter/greeter_test.go.hbs
rename to packages/templates/go-lib/pkg/greeter/greeter_test.go
diff --git a/packages/templates/go-lib/template.json b/packages/templates/go-lib/template.json
new file mode 100644
index 0000000..62143f5
--- /dev/null
+++ b/packages/templates/go-lib/template.json
@@ -0,0 +1,22 @@
+{
+ "schemaVersion": 1,
+ "go": {
+ "modulePrefix": "github.com/example/"
+ },
+ "text": [
+ {
+ "files": [
+ "README.md"
+ ],
+ "from": "one-template-go-lib",
+ "value": "projectNameKebabCase"
+ },
+ {
+ "files": [
+ "pkg/greeter/greeter.go"
+ ],
+ "from": "one-template-go-lib",
+ "value": "projectNameKebabCase"
+ }
+ ]
+}
diff --git a/packages/templates/nestjs-api/package.json b/packages/templates/nestjs-api/package.json
index 29921a7..0ead6c5 100644
--- a/packages/templates/nestjs-api/package.json
+++ b/packages/templates/nestjs-api/package.json
@@ -105,5 +105,5 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4"
+ "packageManager": "pnpm@10.14.0"
}
diff --git a/packages/templates/nextjs-app/package.json b/packages/templates/nextjs-app/package.json
index 3cff56c..493deaa 100644
--- a/packages/templates/nextjs-app/package.json
+++ b/packages/templates/nextjs-app/package.json
@@ -57,5 +57,5 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4"
+ "packageManager": "pnpm@10.14.0"
}
diff --git a/packages/templates/react-spa/package.json b/packages/templates/react-spa/package.json
index 2ad20b0..38774a8 100644
--- a/packages/templates/react-spa/package.json
+++ b/packages/templates/react-spa/package.json
@@ -57,5 +57,5 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4"
+ "packageManager": "pnpm@10.14.0"
}
diff --git a/packages/templates/starlight-docs/package.json b/packages/templates/starlight-docs/package.json
index 467679d..46487fd 100644
--- a/packages/templates/starlight-docs/package.json
+++ b/packages/templates/starlight-docs/package.json
@@ -30,5 +30,5 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4"
+ "packageManager": "pnpm@10.14.0"
}
diff --git a/packages/templates/ts-library/package.json b/packages/templates/ts-library/package.json
index fcafe46..f945588 100644
--- a/packages/templates/ts-library/package.json
+++ b/packages/templates/ts-library/package.json
@@ -48,5 +48,5 @@
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
- "packageManager": "pnpm@12.3.4"
+ "packageManager": "pnpm@10.14.0"
}
From f9b54a9af4efde190a146d202a8acdcc0154c268 Mon Sep 17 00:00:00 2001
From: caorushizi <84996057@qq.com>
Date: Tue, 29 Sep 2026 03:56:44 +0800
Subject: [PATCH 15/16] fix(ci): handle mise trust and platform path aliases
---
.../internal/modules/miseconfig/cache_test.go | 10 ++++-
.../platform/updatecheck/background.go | 28 +++++++++---
.../platform/updatecheck/background_test.go | 45 +++++++++++++++++++
.../updatecheck/worker_integration_test.go | 3 +-
packages/cli/tests/e2e/mise_trust_test.go | 3 ++
5 files changed, 80 insertions(+), 9 deletions(-)
diff --git a/packages/cli/internal/modules/miseconfig/cache_test.go b/packages/cli/internal/modules/miseconfig/cache_test.go
index f85d089..ab13caf 100644
--- a/packages/cli/internal/modules/miseconfig/cache_test.go
+++ b/packages/cli/internal/modules/miseconfig/cache_test.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
+ "slices"
"strings"
"testing"
@@ -45,8 +46,13 @@ func TestGoCacheIncludesExternalWorkspaceSourcesAndRejectsChangedBuild(t *testin
if build.Cache == nil || !build.Cache.Enabled {
t.Fatal("known Go build should cache")
}
- relative, _ := filepath.Rel(filepath.Join(root, "services/api"), external)
- if !strings.Contains(strings.Join(build.Sources, "\n"), filepath.ToSlash(relative)+"/**/*") {
+ // Build resolves workspace aliases (including macOS /var and Windows
+ // short paths), so source globs are relative to the plan's canonical root.
+ relative, err := filepath.Rel(filepath.Join(plan.Root, "services/api"), external)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(build.Sources, filepath.ToSlash(relative)+"/**/*") {
t.Fatal("external Go member not hashed", build.Sources)
}
writeFixture(t, taskfile, "version: '3'\ntasks:\n build:\n cmds: ['go build -o other/server ./cmd/server']\n")
diff --git a/packages/cli/internal/platform/updatecheck/background.go b/packages/cli/internal/platform/updatecheck/background.go
index 9ac50e0..e26afaa 100644
--- a/packages/cli/internal/platform/updatecheck/background.go
+++ b/packages/cli/internal/platform/updatecheck/background.go
@@ -139,12 +139,8 @@ func runWorker(version string, args []string, makeUpdater func() updater) bool {
if err != nil {
return true
}
- workerDir := filepath.Dir(worker)
- cacheDir, err := filepath.EvalSymlinks(filepath.Dir(path))
- if err != nil {
- return true
- }
- if filepath.Dir(workerDir) != cacheDir || !strings.HasPrefix(filepath.Base(workerDir), workerPrefix) {
+ workerDir, valid := validatedWorkerDir(worker, filepath.Dir(path))
+ if !valid {
return true
}
defer os.RemoveAll(workerDir)
@@ -199,3 +195,23 @@ func runWorker(version string, args []string, makeUpdater func() updater) bool {
_ = saveCache(c)
return true
}
+
+// validatedWorkerDir only accepts a copied worker directly inside the cache.
+func validatedWorkerDir(worker, cacheDir string) (string, bool) {
+ // Executable paths may retain /var aliases on macOS or short names on
+ // Windows. Resolve the worker before checking its directory name, then
+ // compare directory identities instead of platform-dependent spellings.
+ workerDir, err := filepath.EvalSymlinks(filepath.Dir(worker))
+ if err != nil || !strings.HasPrefix(filepath.Base(workerDir), workerPrefix) {
+ return "", false
+ }
+ parentInfo, err := os.Stat(filepath.Dir(workerDir))
+ if err != nil {
+ return "", false
+ }
+ cacheInfo, err := os.Stat(cacheDir)
+ if err != nil || !cacheInfo.IsDir() || !os.SameFile(parentInfo, cacheInfo) {
+ return "", false
+ }
+ return workerDir, true
+}
diff --git a/packages/cli/internal/platform/updatecheck/background_test.go b/packages/cli/internal/platform/updatecheck/background_test.go
index 0330f7d..e40bc30 100644
--- a/packages/cli/internal/platform/updatecheck/background_test.go
+++ b/packages/cli/internal/platform/updatecheck/background_test.go
@@ -145,3 +145,48 @@ func TestDetachedWorkerHelper(t *testing.T) {
os.Exit(0)
}
}
+
+func TestWorkerDirectoryValidation(t *testing.T) {
+ root := t.TempDir()
+ cacheDir := filepath.Join(root, "cache")
+ workerDir := filepath.Join(cacheDir, workerPrefix+"test")
+ otherDir := filepath.Join(root, "other", workerPrefix+"test")
+ unprefixedDir := filepath.Join(cacheDir, "ordinary")
+ for _, dir := range []string{workerDir, otherDir, unprefixedDir} {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ check := func(t *testing.T, workerDir, cacheDir string, want bool) {
+ t.Helper()
+ resolved, valid := validatedWorkerDir(filepath.Join(workerDir, executableName()), cacheDir)
+ if valid != want {
+ t.Fatalf("worker %q in cache %q: valid = %v, want %v", workerDir, cacheDir, valid, want)
+ }
+ if valid {
+ wantDir, err := filepath.EvalSymlinks(workerDir)
+ if err != nil || resolved != wantDir {
+ t.Fatalf("worker directory = %q, want %q: %v", resolved, wantDir, err)
+ }
+ }
+ }
+ check(t, workerDir, cacheDir, true)
+ check(t, otherDir, cacheDir, false)
+ check(t, unprefixedDir, cacheDir, false)
+ check(t, workerDir, filepath.Join(root, "missing"), false)
+ t.Run("path aliases", func(t *testing.T) {
+ alias := filepath.Join(root, "alias")
+ if err := os.Symlink(cacheDir, alias); err != nil {
+ t.Skipf("directory symlinks unavailable: %v", err)
+ }
+ check(t, filepath.Join(alias, filepath.Base(workerDir)), cacheDir, true)
+ check(t, workerDir, alias, true)
+ check(t, filepath.Join(alias, filepath.Base(workerDir)), alias, true)
+ // A worker-shaped symlink into another directory must remain invalid.
+ escaped := filepath.Join(cacheDir, workerPrefix+"outside")
+ if err := os.Symlink(otherDir, escaped); err != nil {
+ t.Fatal(err)
+ }
+ check(t, escaped, cacheDir, false)
+ })
+}
diff --git a/packages/cli/internal/platform/updatecheck/worker_integration_test.go b/packages/cli/internal/platform/updatecheck/worker_integration_test.go
index 03e3b74..b94e19d 100644
--- a/packages/cli/internal/platform/updatecheck/worker_integration_test.go
+++ b/packages/cli/internal/platform/updatecheck/worker_integration_test.go
@@ -110,7 +110,8 @@ func TestBackgroundWorkerCompletesAfterCommandExits(t *testing.T) {
}
time.Sleep(20 * time.Millisecond)
}
- t.Fatal("update did not complete after the initiating command exited")
+ c, err := loadCache()
+ t.Fatalf("update did not complete after the initiating command exited: cache=%#v, error=%v", c, err)
}
func TestUpdateIntegrationLauncher(t *testing.T) {
diff --git a/packages/cli/tests/e2e/mise_trust_test.go b/packages/cli/tests/e2e/mise_trust_test.go
index 346b579..842a860 100644
--- a/packages/cli/tests/e2e/mise_trust_test.go
+++ b/packages/cli/tests/e2e/mise_trust_test.go
@@ -18,6 +18,9 @@ func TestE2E_CreateAndAddTrustGeneratedMiseFiles(t *testing.T) {
t.Setenv("ONE_MISE_BINARY", mise)
t.Setenv("MISE_PARANOID", "1")
t.Setenv("MISE_TRUSTED_CONFIG_PATHS", "")
+ // mise-action enables automatic confirmation, which bypasses the trust
+ // checks this test needs to observe even in paranoid mode.
+ t.Setenv("MISE_YES", "0")
parent := filepath.Join(temp, "workspaces")
buildWrite(t, parent, "mise.toml", "[env]\nPARENT_USER_CONFIG='unchanged'\n")
checkTrust := func(path string, want bool) {
From 2f48ce4ea51c19f613630525416016fb0b036678 Mon Sep 17 00:00:00 2001
From: caorushizi <84996057@qq.com>
Date: Tue, 29 Sep 2026 04:02:57 +0800
Subject: [PATCH 16/16] fix(test): isolate CI auto-trust and stabilize search
interaction
---
apps/dashboard/src/pages/WorkspaceHome.test.tsx | 8 ++++++--
packages/cli/tests/e2e/mise_trust_test.go | 5 +++--
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/apps/dashboard/src/pages/WorkspaceHome.test.tsx b/apps/dashboard/src/pages/WorkspaceHome.test.tsx
index 09ec922..532dc17 100644
--- a/apps/dashboard/src/pages/WorkspaceHome.test.tsx
+++ b/apps/dashboard/src/pages/WorkspaceHome.test.tsx
@@ -183,13 +183,17 @@ describe("Workspace discovery and recovery", () => {
);
}
+ // This multi-step jsdom interaction shares CI CPUs with the cold Go build.
it("combines path search with attention filtering and restores the list on clear", async () => {
serveRegistry();
const user = userEvent.setup();
renderHome("/?env=preview");
await screen.findByRole("link", { name: /Alpha/ });
const search = screen.getByRole("textbox", { name: "Search name, path or ID…" });
- await user.type(search, " /WORKSPACES/ALPHA ");
+ // The case/whitespace filter assertion only needs the final query. Pasting
+ // avoids a full workspace-card render for every character on CI runners.
+ await user.click(search);
+ await user.paste(" /WORKSPACES/ALPHA ");
expect(screen.getAllByRole("article")).toHaveLength(1);
expect(screen.getByRole("link", { name: /Alpha/ }).getAttribute("href")).toBe(
"/workspace/alpha-entry?env=preview",
@@ -202,7 +206,7 @@ describe("Workspace discovery and recovery", () => {
await user.click(screen.getByRole("button", { name: /Needs attention/ }));
expect(screen.getAllByRole("article")).toHaveLength(4);
expect(screen.queryByRole("link", { name: /Alpha/ })).toBeNull();
- });
+ }, 10_000);
it("keeps existing workspaces during a failed refresh and supports retry", async () => {
serveRegistry();
diff --git a/packages/cli/tests/e2e/mise_trust_test.go b/packages/cli/tests/e2e/mise_trust_test.go
index 842a860..87be991 100644
--- a/packages/cli/tests/e2e/mise_trust_test.go
+++ b/packages/cli/tests/e2e/mise_trust_test.go
@@ -18,9 +18,10 @@ func TestE2E_CreateAndAddTrustGeneratedMiseFiles(t *testing.T) {
t.Setenv("ONE_MISE_BINARY", mise)
t.Setenv("MISE_PARANOID", "1")
t.Setenv("MISE_TRUSTED_CONFIG_PATHS", "")
- // mise-action enables automatic confirmation, which bypasses the trust
- // checks this test needs to observe even in paranoid mode.
+ // Both mise-action and mise's CI mode enable automatic confirmation,
+ // bypassing the trust checks this test observes even in paranoid mode.
t.Setenv("MISE_YES", "0")
+ t.Setenv("CI", "false")
parent := filepath.Join(temp, "workspaces")
buildWrite(t, parent, "mise.toml", "[env]\nPARENT_USER_CONFIG='unchanged'\n")
checkTrust := func(path string, want bool) {