diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..182cdfed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# One CLI 开发约定 + +本文件适用于整个仓库。 + +## 默认考虑多语言 + +新增或修改用户可见功能时,默认同时支持 `zh-CN` 和 `en-US`,在同一次变更中补齐两种语言。不要等用户反馈中英文混用后再补翻译。 + +### CLI 文案 + +- 复用 `packages/cli/internal/platform/i18n` 的 `T`、`Tf` 和 `Errorf`,同步维护 `locales/zh-CN.json` 与 `locales/en-US.json`。使用能表达用途的语义键,避免在业务代码中硬编码用户可见的中文或英文句子。 +- 覆盖命令帮助、参数说明、交互选项、校验错误、进度、结果、恢复建议、TUI 状态与快捷键说明,以及内置模板的展示名称和说明。 +- 用完整句子的格式模板表达动态文案,避免拼接译文片段。两种语言的格式参数必须匹配;错误包装继续使用 `%w`,保留错误链。 +- Cobra 命令帮助使用 `MarkShort`、`MarkLong`、`MarkFlagUsage` 注册翻译键,使 `RefreshTree` 能在语言确定或切换后更新文案。参数数量校验和 flag 错误复用 i18n 中的公共处理逻辑。 +- 沿用现有语言偏好与 `auto` 解析逻辑,不另设语言环境变量或检测流程。除可刷新的命令元数据外,不在包初始化或全局变量中缓存译文。切换语言后的确认信息使用新语言。 +- 错误输出保留具体原因、项目名、路径和恢复建议;不要用错误码的通用描述覆盖具体错误信息。 + +### Dashboard 文案 + +- 复用 `apps/dashboard/src/lib/i18n.ts` 和现有 locale store,沿用现有字典结构及语言切换机制。 +- 新增页面、组件、空状态、表单提示、通知、按钮、tooltip 和无障碍标签时,同步补齐中英文。 +- 布局要容纳不同语言的文本长度,检查换行、截断、弹窗宽度和窄屏显示。 + +### 保持原样的内容 + +- 命令及 flag 名称、机器可读的 JSON 字段与状态值、错误码、模板及 provider ID 等稳定协议值保持不变,仅在展示层翻译其说明。 +- 用户输入、项目名、路径、自定义模板文案等用户内容保持原样。 +- 子进程及第三方工具的原始日志保留原语言、ANSI 颜色和控制台格式。One CLI 自己输出的说明、上下文和恢复建议跟随当前语言。 + +### 验证 + +- 两份语言字典的键必须完整对应、译文非空、格式占位符兼容。新增翻译键时确认实际使用处能够解析。 +- 根据改动覆盖两种语言的帮助、交互、错误或结果输出,并检查语言切换后没有旧语言残留。涉及持久化偏好的测试使用临时 HOME/config,避免修改开发者设置。 +- 修改提示组件或 TUI 时,检查中文终端显示宽度、快捷键提示和文字对比度,并确认子进程原始输出仍被保留。 +- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `task check`。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78282fc6..a9c2a3c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,14 +6,14 @@ ```bash brew install go go-task node # macOS;Linux 用 apt / dnf 类比 -npm i -g pnpm # 或 corepack enable && corepack prepare pnpm@10 +npm i -g pnpm@10.14.0 # 与根 package.json 的 packageManager 一致 git clone https://github.com/1cli-team/one-cli cd one-cli task install # 打包 Dashboard + CLI,再创建当前平台的本地启动器 one --version # 验证装好 ``` -工具链:**Go 1.25+**、**Node 20+**、**pnpm 10+**。`go-task`(不是 GNU make)是任务总线,跨平台一致。 +工具链:**Go 1.26+**、**pnpm 10.14.0**。Node 推荐使用 **24.x(至少 24.15.0)**,也支持 22.x(至少 22.22.2)或 26+;Dashboard 测试依赖的 jsdom 不再支持 Node 20。`go-task`(不是 GNU make)是任务总线,跨平台一致。 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— > registry / templates / dashboard dist 都由 `task sync-bundled` + diff --git a/README.md b/README.md index fdb7c19b..aa055e6d 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ One CLI is useful when you want to: - start from a clean project foundation - add a frontend, backend, docs site, mobile app, desktop app, or library later -- keep local settings and deployment choices out of random notes +- keep environment configuration and local settings organized - let an AI assistant help without guessing how the project is arranged - use the same simple commands across different kinds of projects @@ -89,11 +89,11 @@ one add nestjs-api --name api |---|---| | `one create ` | Create an empty workspace | | `one add ` | Add another app, service, docs site, or library | -| `one dev [project]` | Run every project, or one selected project, locally | -| `one deploy [project]` | Choose a target on first deploy, then deploy | +| `one dev [projects...]` | Run all or selected projects; native output for one task, TUI for multiple tasks | +| `one build [projects...]` | Build all or selected projects in dependency order; optional bounded concurrency | | `one env` | Review and manage environment variables | -| `one configure` | Manage local connections and preferences | -| `one serve` | Inspect Workspaces and Projects; manage local Profiles and bindings | +| `one login` | Sign in to Infisical with your browser | +| `one serve` | Inspect workspaces, manage the current account and shared credentials | | `one ci [enable\|sync\|disable]` | Optionally manage generated GitHub Actions workflows | Full command docs live at [1cli.dev](https://1cli.dev). @@ -122,23 +122,17 @@ The assistant can read `one.manifest.json` and project README files, then use On ## Local Settings -Some projects need environment values, deployment accounts, or image registry settings. One CLI keeps those in your local user config, not inside the project files you share with the team. +One CLI manages local dotenv and Infisical variables. Run `one login` to sign in with your browser; the single session is stored in the OS keyring, with no plaintext fallback. Use `one whoami` to inspect status and `one logout` to remove the local session. -For a guided browser-based setup: +Run `one serve` for account settings, workspaces, and shared credentials. Workspace and project configuration changes share one reviewed, revision-checked Manifest draft. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. -```bash -one configure open -``` - -The page only binds to your local machine by default, so it is a better place for sensitive values than a chat window or a shared document. Workspace environment Backend and Project configuration edits remain browser drafts until the top-bar save action shows an exact diff. Project changes use atomic revision-checked Manifest patches; Backend changes use the revision-checked env switch workflow, including Infisical project binding initialization. Source files and non-allowlisted Manifest fields remain read-only. Backend changes do not migrate secret values between providers. Infisical workspaces also expose scoped secret CRUD: lists omit values, and cleartext is fetched one key at a time with no-store responses. - -Profile definitions and credentials live in `~/.config/one/config.json` and `credentials.json`. They are machine-global, so Profile CRUD in Settings is not environment-scoped. The Dashboard UI offers Development, Preview, and Production binding contexts; those selections live separately in `~/.config/one/profile-bindings.json`, keyed by canonical Workspace root and environment. These local files never modify the repository manifest; only the explicit reviewed Project draft and environment Backend switch endpoints can do that. +Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one run --global --env dev --path /folder --keys KEY -- command`. Explicit scope and best-effort masking reduce accidental exposure; they do not isolate arbitrary programs running as the same OS user. Use least-privilege remote permissions. ## Project Map Every One CLI project has a `one.manifest.json` file at the root. Most users do not need to edit it by hand. -Think of it as the project map. It records which parts exist, where they live, and which starter created them. One CLI reads it when you add, run, deploy, or inspect parts of the project. `one serve` writes it only after an explicit reviewed, revision-checked Dashboard action; other repository changes stay in the normal code-review workflow. +Think of it as the project map. It records which parts exist, where they live, and which starter created them. One CLI reads it when you add, run, build, or inspect parts of the project. `one serve` writes it only after an explicit reviewed, revision-checked Dashboard action; other repository changes stay in the normal code-review workflow. ## Repository Layout @@ -150,7 +144,7 @@ If you want to work on One CLI itself, the repository is organized like this: | `packages/templates` | Starters used by `one add` | | `skills/one-cli` | Minimal workspace guidance installed by `one skills install` | | `apps/docs` | Documentation website | -| `apps/dashboard` | Local Workspace, Project, and Profile Dashboard opened by `one serve` | +| `apps/dashboard` | Local workspace, account, and global-variable Dashboard opened by `one serve` | | `assets` | Brand assets, including the logo | Common contributor commands: @@ -176,3 +170,36 @@ Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. ## License MIT. + +### Development and build terminals + +```sh +one dev web api # Run a selected set of projects in parallel +one dev --select # Search and select projects interactively +one dev web # Keep the project's native colors, progress, and input +one dev web api --keep-going # Keep peers running if a project exits +one dev web api --ui=stream # Use continuous prefixed logs +one build web api --concurrency=4 # Build ready tasks concurrently, respecting local dependencies +``` + +`--ui=auto` uses a native terminal for one task and a TUI for multiple tasks. +Override it with `raw`, `tui`, or `stream`. TUI and raw require an interactive +terminal with text output; CI, pipes, and JSON/YAML output use streaming logs. +Structured results remain on stdout and task logs go to stderr. `--dry-run` +only prints the selected execution plan. + +In the TUI, use ↑/↓ to select a project, Enter to send it keyboard input, and +Ctrl+] to return to navigation. PgUp/PgDn scroll history, f resumes following, +/ searches projects, and h hides the project list. In dev, r restarts the selected +project and s stops it. Ctrl+C in navigation stops the session and its process +trees. Ctrl+C in input mode is sent to the selected application. By default any +dev process exiting stops the group; `--keep-going` keeps the other projects alive. + +Build concurrency defaults to 1. Selected local Node dependencies run first; +project selection does not implicitly add unselected dependencies. Failed builds +stop new scheduling, finish already running independent builds, and block tasks +that depend on the failure. Build sessions return to the shell automatically. + +Interactive task terminals currently support Unix (including Linux and macOS). +Windows supports native single-task output and streaming multiple tasks; auto +falls back to streaming until a ConPTY adapter is available. diff --git a/Taskfile.yml b/Taskfile.yml index b7621cdd..f823e95d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -55,7 +55,6 @@ tasks: - 'packages/kernel/**/*.go' - 'packages/cli/cmd/**/*.go' - 'packages/cli/internal/**/*.go' - - 'packages/cli/internal/adapters/deploy/kustomize/templates/*' - 'packages/cli/internal/platform/i18n/locales/*.json' - 'packages/cli/internal/resources/bundled/**/*' - 'packages/cli/pkg/**/*.go' @@ -95,12 +94,12 @@ tasks: - 'packages/templates/*/README.md.hbs' verify-preset-codes: - desc: Lock the v1 preset code table (template + deploy + env + container) — codes are append-only and never re-used + desc: Lock the v1 preset code table (template + env) — codes are append-only and never re-used # The test package imports internal/core/template → internal/resources/bundled, so it # needs the embed sources present. Mirrors verify-cli-references. deps: [sync-bundled, sync-web] cmds: - - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestTemplateCodesMatchGoldenAndRegistry|TestDeployCodesMatchGolden|TestEnvCodesMatchGolden|TestContainerCodesMatchGolden|TestGoldenSortedByCode' ./internal/modules/preset/... + - go -C '{{.CLI_DIR}}' test -count=1 -run 'TestTemplateCodesMatchGoldenAndRegistry|TestEnvCodesMatchGolden|TestGoldenSortedByCode' ./internal/modules/preset/... sources: - 'packages/cli/internal/modules/preset/**/*.go' - 'packages/cli/testdata/preset/v1_codes.json' @@ -315,8 +314,8 @@ tasks: Dashboard on http://localhost:5173. Open that Vite URL directly. Workspace and Project data comes from the checked-in Dashboard fixture. - Profile CRUD still reads and writes this machine's real One config; - Profile bindings are real and scoped to the fixture Workspace root. + Login and global-variable operations use the real local Infisical session. + Workspace configuration is scoped to the fixture Workspace root. Both processes stop together when you press Ctrl-C. deps: [sync-bundled, sync-web] @@ -331,7 +330,7 @@ tasks: dev:dashboard: env: VITE_DEV_API_TARGET: http://127.0.0.1:5174 - VITE_DEV_DATA_MODE: fixture-live-profiles + VITE_DEV_DATA_MODE: fixture-live-session cmds: - pnpm --filter one-serve-web dev diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index acdcc3c9..2fd47455 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -2,7 +2,7 @@ "name": "one-serve-web", "version": "0.1.0", "private": true, - "description": "Embedded `one serve` Dashboard for Workspaces, Projects, and machine-level Profiles. Built into the Go binary via go:embed.", + "description": "Embedded `one serve` Dashboard for Workspaces, Projects, and single-account Infisical credentials. Built into the Go binary via go:embed.", "type": "module", "scripts": { "dev": "vite", @@ -18,38 +18,38 @@ "check:fix": "pnpm run lint:fix && pnpm run format:fix" }, "dependencies": { - "@tailwindcss/vite": "^4.2.2", - "axios": "^1.13.5", + "@tailwindcss/vite": "^4.3.3", + "axios": "^1.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "i18next": "^25.6.0", - "lucide-react": "^0.563.0", + "i18next": "^26.4.2", + "lucide-react": "^1.48.0", "radix-ui": "^1.6.7", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-i18next": "^16.1.1", - "react-router-dom": "^7.13.0", - "sonner": "^2.0.7", - "swr": "^2.4.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-i18next": "^17.0.15", + "react-router-dom": "^7.18.4", + "sonner": "^2.0.8", + "swr": "^2.5.1", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", - "zustand": "^5.0.11" + "zustand": "^5.0.15" }, "devDependencies": { - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.2.2", - "@types/react": "^19.2.13", - "@types/react-dom": "^19.2.3", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.6.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "globals": "^17.3.0", - "jsdom": "^25.0.1", - "msw": "^2.14.6", - "oxfmt": "^0.45.0", - "oxlint": "^1.56.0", - "typescript": "^5.9.3", - "vite": "^8.0.1", - "vitest": "^4.1.0" + "globals": "^17.12.0", + "jsdom": "^30.1.1", + "msw": "^2.15.0", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", + "typescript": "^7.0.2", + "vite": "^8.3.1", + "vitest": "^5.0.2" } } diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index a1040360..e6b00a3d 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { useMatch } from "react-router-dom"; +import { AppSidebar } from "@/components/AppSidebar"; import { TopBar } from "@/components/TopBar"; import { AppRoutes } from "@/router/routes"; import { cn } from "@/lib/utils"; @@ -8,18 +9,23 @@ export const App: React.FC = () => { const workspaceMode = Boolean(useMatch("/workspace/:entryId")); return ( -
- {workspaceMode ? null : } -
-
- -
-
+
+ +
+ +
+
+ +
+
+
); }; diff --git a/apps/dashboard/src/api/catalog.ts b/apps/dashboard/src/api/catalog.ts index 60b07b14..58c7f141 100644 --- a/apps/dashboard/src/api/catalog.ts +++ b/apps/dashboard/src/api/catalog.ts @@ -4,10 +4,9 @@ import http from "@/lib/http"; import type { BackendDomain, BackendSpec, CatalogResponse, SectionKey } from "@/types/api"; export const catalogKey = "/catalog"; -export const BACKEND_DOMAINS: readonly BackendDomain[] = ["env", "deploy", "container"]; +export const BACKEND_DOMAINS: readonly BackendDomain[] = ["env"]; const EMPTY_BACKENDS: readonly BackendSpec[] = []; -const CONTAINER_ARTIFACT_CAPABILITIES = new Set(["container/build", "container/push"]); export async function getCatalog(): Promise { return http.get(catalogKey); @@ -24,17 +23,6 @@ export function humanizeBackendName(name: string): string { .join(" "); } -export function backendRequiresContainerArtifact(backend?: BackendSpec): boolean { - return Boolean( - backend?.requirements?.some( - (requirement) => - requirement.kind === "capability" && - !requirement.optional && - CONTAINER_ARTIFACT_CAPABILITIES.has(requirement.name), - ), - ); -} - export function useBackendCatalog() { const result = useSWRImmutable(catalogKey, getCatalog); const backends = result.data?.backends ?? EMPTY_BACKENDS; @@ -49,7 +37,6 @@ export function useBackendCatalog() { return { byID, byDomain, - configurable: backends.filter((backend) => backend.profile.configurable), }; }, [backends]); diff --git a/apps/dashboard/src/api/configure.ts b/apps/dashboard/src/api/configure.ts deleted file mode 100644 index 3d6d729c..00000000 --- a/apps/dashboard/src/api/configure.ts +++ /dev/null @@ -1,59 +0,0 @@ -// api/configure.ts is the typed wrapper around the /api/configure* -// surface in internal/serve/handlers_configure.go. SWR consumers pass -// these as fetchers; mutations go through the explicit methods. - -import http from "@/lib/http"; -import type { - AnyProfile, - ConfigResponse, - RemoveResponse, - SectionResponse, - UpsertResponse, - UseResponse, -} from "@/types/api"; - -export const configKey = "/configure"; - -export async function getConfig(reveal = false): Promise { - return http.get("/configure", { - params: reveal ? { reveal: 1 } : undefined, - }); -} - -export function sectionKey(domain: string, backend: string, reveal = false): string { - return `/configure/${domain}/${backend}` + (reveal ? "?reveal=1" : ""); -} - -export async function getSection( - domain: string, - backend: string, - reveal = false, -): Promise> { - return http.get>(`/configure/${domain}/${backend}`, { - params: reveal ? { reveal: 1 } : undefined, - }); -} - -export async function upsertProfile( - domain: string, - backend: string, - body: { name: string; profile: AnyProfile; use?: boolean }, -): Promise { - return http.post(`/configure/${domain}/${backend}`, body); -} - -export async function removeProfile( - domain: string, - backend: string, - name: string, -): Promise { - return http.delete(`/configure/${domain}/${backend}/${encodeURIComponent(name)}`); -} - -export async function setDefault( - domain: string, - backend: string, - name: string, -): Promise { - return http.put(`/configure/${domain}/${backend}/default`, { name }); -} diff --git a/apps/dashboard/src/api/session.ts b/apps/dashboard/src/api/session.ts new file mode 100644 index 00000000..54ab0b36 --- /dev/null +++ b/apps/dashboard/src/api/session.ts @@ -0,0 +1,72 @@ +import { http } from "@/lib/http"; +export interface SessionInfo { + loggedIn: boolean; + expired: boolean; + email?: string; + userId?: string; + siteUrl?: string; + organizationId?: string; + expiresAt?: string; +} +export interface SessionState { + session: SessionInfo; + login?: { status: "waiting" | "complete" | "failed"; url: string }; + error?: string; +} +export const sessionKey = "/session"; +export const getSession = () => http.get(sessionKey); +export const startLogin = (siteUrl: string) => + http.post<{ url: string }>("/session/login", { siteUrl }); +export const cancelLogin = () => http.delete("/session/login"); +export const logout = () => http.delete("/session"); +export interface RemoteProject { + id: string; + name: string; + orgId: string; + environments: { name: string; slug: string }[]; +} +export const getProjects = () => http.get("/infisical/projects"); +export const createRemoteProject = (name: string) => + http.post("/infisical/projects", { name }, { timeout: 120000 }); +export const getProject = (id: string) => + http.get(`/infisical/projects/${encodeURIComponent(id)}`); +export interface GlobalLocation { + siteUrl: string; + userId: string; + organizationId: string; + projectId: string; + projectName: string; + defaultEnvironment: string; +} +export interface GlobalListing { + location: GlobalLocation; + environment: string; + path: string; + folders: string[]; + variables: { key: string; description?: string }[]; +} +export const locationKey = "/global-env/location"; +export const getLocation = () => http.get<{ location: GlobalLocation | null }>(locationKey); +export const bindLocation = (projectId: string, environment: string) => + http.put<{ location: GlobalLocation }>(locationKey, { projectId, environment }); +export const initializeGlobalLocation = () => + http.post<{ location: GlobalLocation }>(`${locationKey}/default`, {}, { timeout: 120000 }); +export const globalQuery = (environment: string, path: string) => + `?${new URLSearchParams({ env: environment, path })}`; +export const getGlobalListing = (query: string) => + http.get(`/global-env/secrets${query}`); +export const readGlobalSecret = (key: string, query: string) => + http.get<{ value: string }>(`/global-env/secrets/${encodeURIComponent(key)}${query}`); +export const saveGlobalSecret = (key: string, value: string, query: string, existing: boolean) => + http[existing ? "put" : "post"](`/global-env/secrets/${encodeURIComponent(key)}${query}`, { + value, + }); +export const deleteGlobalSecret = (key: string, query: string) => + http.delete(`/global-env/secrets/${encodeURIComponent(key)}${query}`); +export const createGlobalFolder = (name: string, query: string) => + http.post(`/global-env/folders${query}`, { name }); +export function message(error: unknown): string { + return error && typeof error === "object" && "message" in error + ? String(error.message) + : String(error); +} diff --git a/apps/dashboard/src/api/workspace.ts b/apps/dashboard/src/api/workspace.ts index 9fc0a7bc..c3de3925 100644 --- a/apps/dashboard/src/api/workspace.ts +++ b/apps/dashboard/src/api/workspace.ts @@ -1,15 +1,10 @@ // api/workspace.ts exposes Manifest projections, env Backend workflows, and -// machine-local Profile binding mutations. Reviewed Project publication lives +// single-account environment configuration. Reviewed Manifest publication lives // in api/manifest.ts; remote secret operations live in api/secrets.ts. import { workspaceBasePath } from "@/api/workspaces"; import http from "@/lib/http"; -import type { - BackendDomain, - Overview, - ProjectSettingsResponse, - WorkspaceProfileSettings, -} from "@/types/api"; +import type { Overview, ProjectSettingsResponse, WorkspaceEnvironmentSettings } from "@/types/api"; export const overviewKey = "/workspace/overview"; @@ -28,25 +23,15 @@ export async function getOverview(entryId?: string, environment?: string): Promi return http.get(overviewKeyFor(entryId, environment)); } -export function workspaceProfileBindingKey(entryId?: string, environment?: string): string { - return withEnvironment(`${workspaceBasePath(entryId)}/profile-bindings/env`, environment); +export function workspaceEnvironmentKey(entryId?: string, environment?: string): string { + return withEnvironment(`${workspaceBasePath(entryId)}/environment`, environment); } -export async function getWorkspaceProfileBinding( +export async function getWorkspaceEnvironment( entryId?: string, environment?: string, -): Promise { - return http.get(workspaceProfileBindingKey(entryId, environment)); -} - -export async function updateWorkspaceProfileBinding( - profile: string, - entryId?: string, - environment?: string, -): Promise { - return http.put(workspaceProfileBindingKey(entryId, environment), { - profile, - }); +): Promise { + return http.get(workspaceEnvironmentKey(entryId, environment)); } export function workspaceEnvironmentBackendKey(entryId?: string, environment?: string): string { @@ -58,21 +43,24 @@ export async function switchWorkspaceEnvironmentBackend( revision: string, entryId?: string, environment?: string, -): Promise { - return http.put(workspaceEnvironmentBackendKey(entryId, environment), { - backend, - revision, - }); +): Promise { + return http.put( + workspaceEnvironmentBackendKey(entryId, environment), + { + backend, + revision, + }, + ); } export async function initializeWorkspaceEnvironmentBackend( entryId: string | undefined, environment: string, project?: string, -): Promise { +): Promise { const search = new URLSearchParams({ env: environment }); if (project) search.set("project", project); - return http.post( + return http.post( `${workspaceBasePath(entryId)}/environment/backend/initialize?${search.toString()}`, ); } @@ -96,28 +84,3 @@ export async function getProjectSettings( ): Promise { return http.get(projectSettingsKey(project, entryId, environment)); } - -export function projectProfileBindingKey( - project: string, - domain: BackendDomain, - entryId?: string, - environment?: string, -): string { - return withEnvironment( - `${projectBasePath(project, entryId)}/profile-bindings/${domain}`, - environment, - ); -} - -export async function updateProjectProfileBinding( - project: string, - domain: BackendDomain, - profile: string, - entryId?: string, - environment?: string, -): Promise { - return http.put( - projectProfileBindingKey(project, domain, entryId, environment), - { profile }, - ); -} diff --git a/apps/dashboard/src/components/AppSidebar.tsx b/apps/dashboard/src/components/AppSidebar.tsx index 9a793e15..233d3494 100644 --- a/apps/dashboard/src/components/AppSidebar.tsx +++ b/apps/dashboard/src/components/AppSidebar.tsx @@ -1,70 +1,92 @@ -import { House, MoonStar, Settings2, SunMedium } from "lucide-react"; +import { KeyRound, House, Menu, MoonStar, Settings2, SunMedium } from "lucide-react"; import type React from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { EnvironmentNavLink } from "@/features/environment-context/EnvironmentLink"; +import { + EnvironmentLink, + EnvironmentNavLink, +} from "@/features/environment-context/EnvironmentLink"; +import { SessionStatus } from "@/features/infisical-session/AccountSettings"; import { WorkspaceRail } from "@/features/workspace-registry/WorkspaceRail"; import { useThemeStore } from "@/lib/stores/theme"; import { cn } from "@/lib/utils"; const navItemClass = ({ isActive }: { isActive: boolean }) => cn( - "relative flex h-10 items-center gap-2.5 px-3 text-xs transition-colors", + "relative flex min-h-10 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors duration-150", isActive - ? "bg-sidebar-active font-semibold text-sidebar-foreground before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary" - : "text-sidebar-muted hover:bg-sidebar-active/60 hover:text-sidebar-foreground", + ? "bg-sidebar-active font-medium text-primary-text" + : "text-sidebar-muted hover:bg-muted hover:text-sidebar-foreground", ); -export const AppSidebar: React.FC = () => { +function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { const { mode, toggle } = useThemeStore(); const { t } = useTranslation(); - const logoSrc = mode === "dark" ? "/brand/icon-inverted.svg" : "/brand/icon.svg"; - return ( - +
); -}; +} + +export const AppSidebar: React.FC = () => ( + +); + +export function MobileNavigation() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const navigationRef = useRef(null); + return ( + + + + + { + event.preventDefault(); + navigationRef.current?.querySelector("a")?.focus(); + }} + side="left" + closeLabel={t("sidebar.closeNavigation")} + className="w-80 gap-0 rounded-none bg-sidebar [&>button]:top-3 [&>button]:right-2" + > + {t("sidebar.navigation")} + {t("workspaces.home.description")} + setOpen(false)} /> + + + ); +} diff --git a/apps/dashboard/src/components/LanguageSwitcher.tsx b/apps/dashboard/src/components/LanguageSwitcher.tsx index d4d4c997..b734cc0c 100644 --- a/apps/dashboard/src/components/LanguageSwitcher.tsx +++ b/apps/dashboard/src/components/LanguageSwitcher.tsx @@ -32,26 +32,27 @@ const MENU_WIDTH = 160; const TRIGGER_WIDTH = 28; const CENTERED_END_OFFSET = -(MENU_WIDTH - TRIGGER_WIDTH) / 2; -export function LanguageSwitcher() { +export function LanguageSwitcher({ showLabel = false }: { showLabel?: boolean }) { const { mode, setMode } = useLocaleStore(); const { t } = useTranslation(); return ( diff --git a/apps/dashboard/src/components/TopBar.test.tsx b/apps/dashboard/src/components/TopBar.test.tsx index b326be19..5acc79bd 100644 --- a/apps/dashboard/src/components/TopBar.test.tsx +++ b/apps/dashboard/src/components/TopBar.test.tsx @@ -4,7 +4,6 @@ import { MemoryRouter } from "react-router-dom"; import { SWRConfig } from "swr"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { applyManifestDraft, previewManifestDraft } from "@/api/manifest"; -import { switchWorkspaceEnvironmentBackend } from "@/api/workspace"; import { workspacesKey } from "@/api/workspaces"; import { ManifestSaveControl, TopBar } from "@/components/TopBar"; import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; @@ -15,7 +14,6 @@ vi.mock("@/api/manifest", () => ({ applyManifestDraft: vi.fn(), previewManifestDraft: vi.fn(), })); -vi.mock("@/api/workspace", () => ({ switchWorkspaceEnvironmentBackend: vi.fn() })); const emptyRegistry: WorkspacesResponse = { schema: "one-cli/workspaces/v1", @@ -145,16 +143,6 @@ describe("TopBar and manifest review", () => { }); it("reviews and publishes a Workspace environment backend draft", async () => { - vi.mocked(switchWorkspaceEnvironmentBackend).mockResolvedValue({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - revision: "sha256:next", - domain: "env", - backend: "dotenv", - configurable: false, - selectedProfile: "", - }); useManifestDraftStore.getState().stageWorkspaceSection({ entryId: "demo-entry", revision: "sha256:base", @@ -181,26 +169,14 @@ describe("TopBar and manifest review", () => { await user.click(within(dialog).getByRole("button", { name: "Save to manifest" })); await waitFor(() => - expect(switchWorkspaceEnvironmentBackend).toHaveBeenCalledWith( - "dotenv", - "sha256:base", + expect(applyManifestDraft).toHaveBeenCalledWith( + { revision: "sha256:base", workspace: { environment: { backend: "dotenv" } }, changes: [] }, "demo-entry", - "dev", ), ); - expect(applyManifestDraft).not.toHaveBeenCalled(); }); - it("rebases remaining Project changes after a successful Backend switch", async () => { - vi.mocked(switchWorkspaceEnvironmentBackend).mockResolvedValue({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - revision: "sha256:after-switch", - domain: "env", - backend: "dotenv", - configurable: false, - }); + it("retains the entire draft when atomic publication fails", async () => { vi.mocked(applyManifestDraft).mockRejectedValue({ status: 500, code: "ONE_CLI_ERROR", @@ -236,7 +212,8 @@ describe("TopBar and manifest review", () => { expect(await screen.findByText("Project publication failed.")).toBeDefined(); expect(applyManifestDraft).toHaveBeenCalledWith( { - revision: "sha256:after-switch", + revision: "sha256:base", + workspace: { environment: { backend: "dotenv" } }, changes: [ { project: "web", @@ -247,8 +224,8 @@ describe("TopBar and manifest review", () => { "demo-entry", ); const remaining = useManifestDraftStore.getState().drafts["demo-entry"]; - expect(remaining.revision).toBe("sha256:after-switch"); - expect(remaining.workspace).toBeUndefined(); + expect(remaining.revision).toBe("sha256:base"); + expect(remaining.workspace).toEqual({ environment: { backend: "dotenv" } }); expect(Object.keys(remaining.changes)).toEqual(["web"]); }); }); diff --git a/apps/dashboard/src/components/TopBar.tsx b/apps/dashboard/src/components/TopBar.tsx index cd49c239..8d91cf8a 100644 --- a/apps/dashboard/src/components/TopBar.tsx +++ b/apps/dashboard/src/components/TopBar.tsx @@ -1,22 +1,9 @@ -import { AlertTriangle, FilePenLine, Save, Trash2 } from "lucide-react"; import type React from "react"; -import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useMatch } from "react-router-dom"; -import useSWR, { useSWRConfig } from "swr"; +import useSWR from "swr"; import { humanizeBackendName, useBackendCatalog } from "@/api/catalog"; -import { applyManifestDraft, previewManifestDraft } from "@/api/manifest"; -import { switchWorkspaceEnvironmentBackend } from "@/api/workspace"; import { getWorkspaces, workspacesKey } from "@/api/workspaces"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Breadcrumb, BreadcrumbItem, @@ -25,25 +12,10 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { environmentFromSearch } from "@/features/environment-context/environment"; -import { - manifestDraftKey, - useManifestDraftStore, -} from "@/features/manifest-draft/manifest-draft-store"; -import { - sideBySideDiffRows, - type UnifiedDiffLine, - unifiedFileDiff, -} from "@/features/manifest-draft/unified-diff"; -import { useToast } from "@/hooks/useToast"; -import { useThemeStore } from "@/lib/stores/theme"; -import { SettingsDialog } from "@/router/SettingsDialog"; -import type { ApplyManifestRequest, HttpError, PreviewManifestResponse } from "@/types/api"; +import { MobileNavigation } from "@/components/AppSidebar"; import type { SectionKey } from "@/types/api"; - +export { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; interface TopBarProps { devDataMode?: string; } @@ -54,291 +26,49 @@ export const TopBar: React.FC = () => { const profileMatch = useMatch("/profile"); const settingsSectionMatch = useMatch("/settings/:domain/:backend"); const settingsMatch = useMatch("/settings"); + const globalMatch = useMatch("/global"); const workspaceMatch = useMatch("/workspace/:entryId"); + const { pathname } = useLocation(); - const { mode } = useThemeStore(); const detailMatch = settingsSectionMatch ?? sectionMatch; - const logoSrc = mode === "dark" ? "/brand/icon-inverted.svg" : "/brand/icon.svg"; return ( -
-
- - One CLI -
-

- One CLI -

-

- {t("sidebar.brand")} -

-
-
-
+
+
+ +
- + {detailMatch ? ( + ) : globalMatch ? ( + + {t("global.title")} + ) : settingsMatch ? ( ) : profileMatch ? ( ) : workspaceMatch ? ( - ) : ( + ) : pathname === "/" ? ( + ) : ( + + {t("notFound.title")} + )}
-
- {workspaceMatch ? ( - - ) : null} - {pathname === "/" ? : null} -
); }; -const WorkspaceHeaderActions: React.FC<{ entryId: string }> = ({ entryId }) => { - return ; -}; - -export const ManifestSaveControl: React.FC<{ entryId: string }> = ({ entryId }) => { - const { t } = useTranslation(); - const { search } = useLocation(); - const { mutate } = useSWRConfig(); - const toast = useToast(); - const draft = useManifestDraftStore((state) => state.drafts[manifestDraftKey(entryId)]); - const commitWorkspaceSection = useManifestDraftStore((state) => state.commitWorkspaceSection); - const clearWorkspace = useManifestDraftStore((state) => state.clearWorkspace); - const [open, setOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [previewing, setPreviewing] = useState(false); - const [preview, setPreview] = useState(); - const [error, setError] = useState(""); - const diffLines = useMemo( - () => (preview ? unifiedFileDiff(preview.before, preview.after) : []), - [preview], - ); - const diffRows = useMemo(() => sideBySideDiffRows(diffLines), [diffLines]); - - if (!draft) return null; - const changedCount = draft.summaries.filter((summary) => summary.changed).length; - - async function showPreview() { - if (!draft || previewing) return; - setOpen(true); - setPreview(undefined); - setPreviewing(true); - setError(""); - try { - const result = await previewManifestDraft( - { - revision: draft.revision, - workspace: draft.workspace, - changes: Object.values(draft.changes), - }, - entryId, - ); - setPreview(result); - } catch (cause) { - const failure = cause as HttpError; - setError( - failure.code === "SERVE_MANIFEST_CONFLICT" - ? t("manifestDraft.conflict") - : failure.message || t("manifestDraft.previewFailed"), - ); - } finally { - setPreviewing(false); - } - } - - async function save() { - if (!draft || saving) return; - setSaving(true); - setError(""); - try { - let revision = draft.revision; - const workspaceEnvironment = draft.workspace?.environment; - if (workspaceEnvironment) { - const switched = await switchWorkspaceEnvironmentBackend( - workspaceEnvironment.backend, - revision, - entryId, - environmentFromSearch(search), - ); - revision = switched.revision; - // The Backend switch and Project draft are separate revision-checked - // publications. Rebase any remaining Project changes immediately so - // a later failure can be retried without replaying the completed switch. - commitWorkspaceSection(entryId, "environment", revision); - } - const changes = Object.values(draft.changes); - const payload: ApplyManifestRequest = { - revision, - changes, - }; - if (changes.length > 0) await applyManifestDraft(payload, entryId); - clearWorkspace(entryId); - setOpen(false); - await mutate( - (key) => typeof key === "string" && key.startsWith(`/workspaces/${entryId}/`), - undefined, - { revalidate: true }, - ); - toast.success(t("manifestDraft.saved")); - } catch (cause) { - const failure = cause as HttpError; - setError( - failure.code === "SERVE_MANIFEST_CONFLICT" - ? t("manifestDraft.conflict") - : failure.message || t("manifestDraft.saveFailed"), - ); - } finally { - setSaving(false); - } - } - - return ( - <> - - - !saving && setOpen(next)}> - - -
-
- -
-
- {t("manifestDraft.title")} - - {t("manifestDraft.description")} - -
-
-
- -
- {previewing ? ( -
- - {t("manifestDraft.previewing")} -
- ) : preview ? ( -
-
-
- - {t("manifestDraft.currentManifest")} - - a/one.manifest.json -
-
- - {t("manifestDraft.updatedManifest")} - - b/one.manifest.json -
-
-
- {diffRows.map((row, index) => ( -
- - -
- ))} -
-
- ) : null} -
- - {error ? ( -

- {error} -

- ) : null} - - - -
- {t("form.cancel")} - -
-
-
-
- - ); -}; - -const ManifestDiffCell: React.FC<{ - line?: UnifiedDiffLine; - side: "before" | "after"; -}> = ({ line, side }) => { - const lineNumber = side === "before" ? line?.beforeLine : line?.afterLine; - const toneClass = !line - ? "bg-muted/30" - : line.kind === "removed" - ? "bg-error-surface text-error-foreground" - : line.kind === "added" - ? "bg-success-surface text-success-foreground" - : "text-foreground"; - const dividerClass = side === "before" ? "border-b border-border md:border-r md:border-b-0" : ""; - - return ( -
- - {lineNumber} - - - {line?.kind === "removed" ? "-" : line?.kind === "added" ? "+" : " "} - - {line?.text} -
- ); -}; - const HomeCrumb: React.FC = () => { const { t } = useTranslation(); return ( @@ -380,9 +110,9 @@ const WorkspaceCrumb: React.FC<{ entryId: string }> = ({ entryId }) => { - {workspace?.name ?? t("topbar.home")} + {workspace?.name ?? t("workspaces.unknown.title")} {workspace?.id ? ( - + {workspace.id} ) : null} diff --git a/apps/dashboard/src/components/ui/alert-dialog.tsx b/apps/dashboard/src/components/ui/alert-dialog.tsx index 337f5572..e7d3fb63 100644 --- a/apps/dashboard/src/components/ui/alert-dialog.tsx +++ b/apps/dashboard/src/components/ui/alert-dialog.tsx @@ -67,7 +67,7 @@ function AlertDialogContent({ } }} className={cn( - "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-hidden rounded-xl border border-border bg-card p-5 shadow-lg duration-200 outline-none data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg data-[size=wide]:sm:max-w-[min(96vw,90rem)]", + "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-5 max-h-[calc(100dvh-4rem)] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg duration-200 outline-none data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:ud-sm:max-w-[26.25rem] data-[size=wide]:ud-sm:max-w-[min(calc(100vw-4rem),67.5rem)]", className, )} {...props} diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx index 7be0cd3a..02ef6c3b 100644 --- a/apps/dashboard/src/components/ui/badge.tsx +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -4,11 +4,15 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const badgeVariants = cva( - "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-sm border border-transparent px-2 py-1 font-mono text-[10px] font-semibold leading-none tracking-[0.05em] whitespace-nowrap uppercase transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-sm border border-transparent px-2 py-0.5 text-xs font-medium leading-5 whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", { variants: { variant: { - default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + default: "bg-primary-action text-primary-foreground [a&]:hover:bg-primary-hover", + success: "bg-success-surface text-success-foreground", + warning: "bg-warning-surface text-warning-foreground", + error: "bg-error-surface text-error-foreground", + muted: "bg-muted text-muted-foreground", secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index 87f92240..f9340d5e 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -4,28 +4,33 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,transform] duration-200 outline-none active:translate-y-px focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 disabled:active:translate-y-0 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] duration-150 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: + "bg-primary-action text-primary-foreground hover:bg-primary-hover active:bg-primary-hover", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50", + "border border-input bg-card hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85", + navigation: + "justify-start text-left font-normal hover:bg-muted aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground", + "danger-ghost": "text-muted-foreground hover:bg-error-surface hover:text-error-foreground", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", + link: "text-primary-text underline-offset-4 hover:underline", }, size: { - default: "h-10 px-4 py-2 has-[>svg]:px-3.5", - xs: "h-7 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-9 gap-1.5 px-3 text-xs has-[>svg]:px-2.5", - lg: "h-11 px-5 has-[>svg]:px-4", - icon: "size-10", - "icon-xs": "size-7 [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-9", - "icon-lg": "size-10", + navigation: "h-auto min-h-14 w-full gap-3 px-3 py-2", + default: "h-8 px-3 py-1 has-[>svg]:px-3", + xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1.5 px-3 text-xs has-[>svg]:px-2.5", + lg: "h-10 px-5 has-[>svg]:px-4", + icon: "size-7", + "icon-xs": "size-5 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-6", + "icon-lg": "size-8", }, }, defaultVariants: { @@ -51,6 +56,7 @@ function Button({ return ( ) {
& { showCloseButton?: boolean; }) { + const { t } = useTranslation(); const returnFocusRef = React.useRef(null); return ( @@ -70,7 +72,7 @@ function DialogContent({ } }} className={cn( - "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-hidden rounded-2xl border border-border bg-card p-5 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg", + "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-5 max-h-[calc(100dvh-4rem)] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 ud-sm:max-w-[37.5rem]", className, )} {...props} @@ -79,10 +81,10 @@ function DialogContent({ {showCloseButton && ( - - Close + )} @@ -94,7 +96,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -128,7 +130,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps ); diff --git a/apps/dashboard/src/components/ui/discard-dialog.tsx b/apps/dashboard/src/components/ui/discard-dialog.tsx new file mode 100644 index 00000000..015ac0a0 --- /dev/null +++ b/apps/dashboard/src/components/ui/discard-dialog.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +export function DiscardDialog({ + open, + onOpenChange, + onDiscard, +}: { + open: boolean; + onOpenChange(open: boolean): void; + onDiscard(): void; +}) { + const { t } = useTranslation(); + return ( + + + + {t("form.discardTitle")} + {t("form.discardDescription")} + + + {t("form.continueEditing")} + + {t("global.discard")} + + + + + ); +} diff --git a/apps/dashboard/src/components/ui/field.tsx b/apps/dashboard/src/components/ui/field.tsx index 631684c5..b146d609 100644 --- a/apps/dashboard/src/components/ui/field.tsx +++ b/apps/dashboard/src/components/ui/field.tsx @@ -49,7 +49,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { ); } -const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", { +const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", { variants: { orientation: { vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], diff --git a/apps/dashboard/src/components/ui/icon-button.tsx b/apps/dashboard/src/components/ui/icon-button.tsx new file mode 100644 index 00000000..a3ab0842 --- /dev/null +++ b/apps/dashboard/src/components/ui/icon-button.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export function IconButton({ label, ...props }: ComponentProps & { label: string }) { + return ( + + + + + ) : undefined + } + /> + {session.error || location.error ? ( + { + void session.mutate(); + void location.mutate(); + }} + > + + {t("secrets.retry")} + + } + > + {message(session.error || location.error)} + + ) : session.isLoading || location.isLoading ? ( +
+ + +
+ ) : !signedIn ? ( + + + + + + ) : ( + <> + {mismatched && {t("global.mismatch")}} + {!current || configure || mismatched ? ( + { + await location.mutate(); + setConfigure(false); + }} + onCancel={current && !mismatched ? () => setConfigure(false) : undefined} + /> + ) : ( + + )} + + )} +
+ ); +} + +function VariableBrowser({ location }: { location: GlobalLocation }) { + const { t } = useTranslation(); + const toast = useToast(); + const [environment, setEnvironment] = useState(location.defaultEnvironment); + const [path, setPath] = useState("/"); + const [search, setSearch] = useState(""); + const detail = useSWR(`/infisical/projects/${location.projectId}`, () => + getProject(location.projectId), + ); + const query = globalQuery(environment, path); + const listing = useSWR( + `/global-env/secrets:${location.userId}:${location.siteUrl}:${location.projectId}${query}`, + () => getGlobalListing(query), + ); + const [revealed, setRevealed] = useState>({}); + const epoch = useRef(0); + const pending = useRef(false); + const actionTrigger = useRef(null); + const [editor, setEditor] = useState<{ key: string; value: string; existing: boolean } | null>( + null, + ); + const [deleting, setDeleting] = useState(null); + const [folder, setFolder] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [showValue, setShowValue] = useState(false); + const [discard, setDiscard] = useState(false); + const dirty = + Boolean(editor && (editor.value || (!editor.existing && editor.key))) || Boolean(folder); + useEffect(() => { + epoch.current++; + setRevealed({}); + setEditor(null); + setDeleting(null); + setFolder(null); + setError(""); + setSearch(""); + setShowValue(false); + setDiscard(false); + return () => { + epoch.current++; + }; + }, [query]); + useEffect(() => { + if (!dirty) return; + const warn = (e: BeforeUnloadEvent) => e.preventDefault(); + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + async function action(fn: () => Promise) { + if (pending.current) return; + pending.current = true; + setBusy(true); + setError(""); + const current = epoch.current; + try { + await fn(); + } catch (e) { + if (current === epoch.current) setError(message(e)); + } finally { + pending.current = false; + setBusy(false); + } + } + async function read(key: string, copy: boolean) { + const current = epoch.current; + await action(async () => { + const { value } = await readGlobalSecret(key, query); + if (current !== epoch.current) return; + if (copy) { + await navigator.clipboard.writeText(value); + toast.success(t("global.copied")); + } else setRevealed((v) => ({ ...v, [key]: value })); + }); + } + function openEditor(key = "", existing = false) { + if (!existing) actionTrigger.current = null; + setError(""); + setShowValue(false); + setEditor({ key, value: "", existing }); + } + function closeEditor() { + if (busy) return; + if (dirty) setDiscard(true); + else { + setEditor(null); + setFolder(null); + setError(""); + } + } + const editing = editor !== null || folder !== null || deleting !== null; + const variables = + listing.data?.variables.filter((v) => + v.key.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()), + ) ?? []; + const unavailable = busy || editing || listing.isLoading || Boolean(listing.error); + return ( + <> + +
+ + + { + void listing.mutate(); + void detail.mutate(); + }} + > + + +
+ } + /> +
+ {listing.error || detail.error || (error && !editing) ? ( +
+ {error || message(listing.error || detail.error)} +
+ ) : null} +
+ +
+
+ setPath(path.slice(0, path.lastIndexOf("/")) || "/")} + > + + +
+
+
+ setSearch(e.target.value)} + /> +
+ +
+ {listing.isLoading ? ( +
+ + + +
+ ) : listing.data && variables.length > 0 ? ( + + + + {t("global.key")} + {t("global.value")} + {t("global.actions")} + + + + {variables.map((v) => ( + + + {v.key} + {v.description && ( +

+ {v.description} +

+ )} +
+ + {revealed[v.key] ?? "••••••••"} + + +
+ + revealed[v.key] !== undefined + ? setRevealed((r) => { + const next = { ...r }; + delete next[v.key]; + return next; + }) + : void read(v.key, false) + } + > + {revealed[v.key] !== undefined ? : } + + void read(v.key, true)} + > + + + + + + + + openEditor(v.key, true)}> + + {t("global.edit")} + + + { + setError(""); + setDeleting(v.key); + }} + > + + {t("global.delete")} + + + +
+
+
+ ))} +
+
+ ) : listing.data && !listing.error ? ( + + {search.trim() ? ( + + ) : ( + + )} + + ) : null} +
+
+ + { + if (!open) closeEditor(); + }} + > + { + if (actionTrigger.current?.isConnected) { + event.preventDefault(); + actionTrigger.current.focus(); + actionTrigger.current = null; + } + }} + > + + {editor?.existing ? t("global.edit") : t("global.add")} + + {location.projectName} · {environment} · {path} + + +
{ + e.preventDefault(); + if (!editor?.key.trim()) return; + void action(async () => { + await saveGlobalSecret(editor.key.trim(), editor.value, query, editor.existing); + setRevealed({}); + setEditor(null); + await listing.mutate(); + toast.success(t("global.saved")); + }); + }} + > + + {t("global.key")} + setEditor((v) => (v ? { ...v, key: e.target.value } : v))} + /> + + +
+ {t("global.newValue")} + setShowValue(!showValue)} + > + {showValue ? : } + +
+ setEditor((v) => (v ? { ...v, value: e.target.value } : v))} + /> +
+

{t("secrets.editorDescription")}

+ {error && {error}} + + + + +
+
+
+ { + if (!open && !busy) setDeleting(null); + }} + > + { + if (actionTrigger.current?.isConnected) { + event.preventDefault(); + actionTrigger.current.focus(); + actionTrigger.current = null; + } + }} + > + + {t("global.delete")} + + {t("global.deleteHint", { + key: deleting, + project: location.projectName, + environment, + path, + })} + + + {error && {error}} + + {t("form.cancel")} + + + + + { + if (!open) closeEditor(); + }} + > + + + {t("global.addFolder")} + + {environment} · {path} + + +
{ + e.preventDefault(); + if (!folder?.trim()) return; + void action(async () => { + await createGlobalFolder(folder.trim(), query); + setFolder(null); + await listing.mutate(); + toast.success(t("global.folderCreated")); + }); + }} + > + + {t("global.folderName")} + setFolder(e.target.value)} + /> + + {error && {error}} + + + + +
+
+
+ { + setEditor(null); + setFolder(null); + setError(""); + setDiscard(false); + }} + /> + + ); +} diff --git a/apps/dashboard/src/features/global-variables/LocationPicker.tsx b/apps/dashboard/src/features/global-variables/LocationPicker.tsx new file mode 100644 index 00000000..4c1651db --- /dev/null +++ b/apps/dashboard/src/features/global-variables/LocationPicker.tsx @@ -0,0 +1,284 @@ +import { DiscardDialog } from "@/components/ui/discard-dialog"; +import { Database, FolderPlus, RefreshCw, Save } from "lucide-react"; +import { ErrorNotice, SectionHeading } from "@/components/ui/page-layout"; +import { Spinner } from "@/components/ui/spinner"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR, { useSWRConfig } from "swr"; +import { + bindLocation, + createRemoteProject, + getProject, + getProjects, + initializeGlobalLocation, + message, + type GlobalLocation, + type RemoteProject, +} from "@/api/session"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function LocationPicker({ + initial, + onSaved, + onCancel, +}: { + initial?: GlobalLocation; + onSaved: () => Promise; + onCancel?: () => void; +}) { + const { t } = useTranslation(); + const { mutate } = useSWRConfig(); + const projects = useSWR("/infisical/projects", getProjects); + const [project, setProject] = useState(initial?.projectId ?? ""); + const [environment, setEnvironment] = useState(initial?.defaultEnvironment ?? ""); + const detail = useSWR(project ? `/infisical/projects/${project}` : null, () => + getProject(project), + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + const [createError, setCreateError] = useState(""); + const [discard, setDiscard] = useState(false); + function closeCreation() { + if (busy) return; + if (name.trim()) setDiscard(true); + else setCreating(false); + } + + async function save(useDefault: boolean) { + if (busy) return; + setBusy(true); + setError(""); + try { + if (useDefault) await initializeGlobalLocation(); + else await bindLocation(project, environment); + await onSaved(); + } catch (e) { + setError(message(e)); + // Setup may have created the remote project before a later step failed. + void projects.mutate().catch(() => undefined); + } finally { + setBusy(false); + } + } + async function create() { + if (busy || !name.trim()) return; + setBusy(true); + setCreateError(""); + try { + const created = await createRemoteProject(name.trim()); + await mutate(`/infisical/projects/${created.id}`, created, { revalidate: false }); + await projects.mutate( + (current: RemoteProject[] | undefined) => + [...(current ?? []).filter((p) => p.id !== created.id), created].sort((a, b) => + a.name.localeCompare(b.name), + ), + { revalidate: false }, + ); + setProject(created.id); + setEnvironment(created.environments.some((e) => e.slug === "dev") ? "dev" : ""); + setError(""); + setCreating(false); + } catch (e) { + setCreateError(message(e)); + void projects.mutate().catch(() => undefined); + } finally { + setBusy(false); + } + } + return ( + <> + + + + {!initial ? ( +
+
+

{t("global.defaultLocation")}

+

shared-credentials / dev /

+

{t("global.defaultLocationHint")}

+
+ +
+ ) : null} + +
+
+ +
+ + +
+
+
+ + +
+
+ {projects.data?.length === 0 ? ( +

{t("global.noProjects")}

+ ) : null} + {error || projects.error || detail.error ? ( + { + void projects.mutate(); + void detail.mutate(); + }} + > + + {t("secrets.retry")} + + ) : undefined + } + > + {error || message(projects.error || detail.error)} + + ) : null} +
+ + {onCancel ? ( + + ) : null} +
+
+
+ { + if (!open) closeCreation(); + }} + > + + + {t("global.createProject")} + {t("global.createProjectHint")} + +
{ + e.preventDefault(); + if (!busy && name.trim()) void create(); + }} + > +
+ + setName(e.target.value)} + disabled={busy} + placeholder="shared-credentials" + /> +
+ {createError ? ( +

+ {createError} +

+ ) : null} + + + + +
+
+
+ { + setCreating(false); + setDiscard(false); + setName(""); + }} + /> + + ); +} diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx new file mode 100644 index 00000000..1e2017be --- /dev/null +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx @@ -0,0 +1,68 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { SWRConfig } from "swr"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "@/api/session"; +import i18n from "@/lib/i18n"; +import { AccountSettings } from "./AccountSettings"; +vi.mock("@/api/session", async (original) => ({ + ...(await original()), + getSession: vi.fn(), + startLogin: vi.fn(), + cancelLogin: vi.fn(), + logout: vi.fn(), +})); +beforeEach(async () => { + vi.resetAllMocks(); + await i18n.changeLanguage("en-US"); +}); +function mount() { + return render( + new Map(), dedupingInterval: 0, shouldRetryOnError: false }} + > + + + + , + ); +} +describe("account state and recovery", () => { + it("shows only a loading state before the session resolves", async () => { + let finish!: (value: api.SessionState) => void; + vi.mocked(api.getSession).mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + mount(); + expect(screen.getByRole("status")).toBeDefined(); + expect(screen.queryByRole("button", { name: "Sign in with browser" })).toBeNull(); + await act(async () => + finish({ session: { loggedIn: true, expired: false, email: "demo@example.com" } }), + ); + expect(await screen.findByText("Connected")).toBeDefined(); + expect(screen.getByText("demo@example.com")).toBeDefined(); + }); + it("offers retry on session failure and recovers to the signed-out state", async () => { + vi.mocked(api.getSession) + .mockRejectedValueOnce(new Error("Session unavailable")) + .mockResolvedValue({ session: { loggedIn: false, expired: false } }); + mount(); + const user = userEvent.setup(); + expect(await screen.findByText("Session unavailable")).toBeDefined(); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: "Sign in with browser" })).toBeDefined(); + }); + it("keeps the waiting login visible with reopen and cancel actions", async () => { + vi.mocked(api.getSession).mockResolvedValue({ + session: { loggedIn: false, expired: false }, + login: { status: "waiting", url: "https://app.infisical.com/test-login" }, + }); + mount(); + expect(await screen.findByRole("link", { name: "Reopen login page" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); + expect(api.startLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx new file mode 100644 index 00000000..da6ecd51 --- /dev/null +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx @@ -0,0 +1,250 @@ +import { + ExternalLink, + KeyRound, + Languages, + LogIn, + LogOut, + RefreshCw, + Settings2, + ShieldCheck, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge } from "@/components/ui/badge"; +import { ErrorNotice, PageHeader, SectionHeading } from "@/components/ui/page-layout"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR, { useSWRConfig } from "swr"; +import { cancelLogin, getSession, logout, message, sessionKey, startLogin } from "@/api/session"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; + +export function AccountSettings() { + const { t } = useTranslation(); + const state = useSWR(sessionKey, getSession, { refreshInterval: 2000 }); + const { mutate } = useSWRConfig(); + const [site, setSite] = useState("https://app.infisical.com"); + const [custom, setCustom] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const waiting = state.data?.login?.status === "waiting"; + async function perform(action: () => Promise) { + if (busy) return; + setBusy(true); + setError(""); + try { + await action(); + await mutate(sessionKey); + } catch (e) { + setError(message(e)); + } finally { + setBusy(false); + } + } + async function login() { + if (busy) return; + // Open synchronously from the click to avoid popup blocking after the API call. + const popup = window.open("about:blank", "_blank"); + if (popup) popup.opener = null; + await perform(async () => { + try { + const attempt = await startLogin(site); + if (popup) popup.location.href = attempt.url; + } catch (e) { + popup?.close(); + throw e; + } + }); + } + return ( +
+ + + + {t("session.connected")} + ) : undefined + } + /> + {state.isLoading && !state.data ? ( +
+ + +
+ ) : state.data?.session.loggedIn ? ( + <> +
+
+

{t("session.account")}

+

{state.data.session.email || "—"}

+
+
+

{t("session.site")}

+

{state.data.session.siteUrl}

+
+
+

{t("session.organization")}

+

+ {state.data.session.organizationId || "—"} +

+
+
+
+ + +
+ + ) : !state.error ? ( + <> +

+ {state.data?.session.expired ? t("session.expired") : t("session.signedOut")} +

+ {waiting ? ( +
+

+ + {t("session.waiting")} +

+
+ + +
+
+ ) : ( +
{ + e.preventDefault(); + if (!busy) void login(); + }} + > + {custom && ( +
+ + setSite(e.target.value)} + placeholder="https://app.infisical.com" + disabled={busy} + /> +
+ )} +
+ + +
+
+ )} + + ) : null} + {error || state.error || state.data?.error ? ( + void state.mutate()} + > + + {t("secrets.retry")} + + ) : undefined + } + > + {error || (state.error ? message(state.error) : state.data?.error)} + + ) : null} +
+
+ + + + + + +
+ ); +} + +export function SessionStatus() { + const { t } = useTranslation(); + const state = useSWR(sessionKey, getSession, { refreshInterval: 5000 }); + return ( + + {state.error + ? t("session.unavailable") + : state.data?.session.loggedIn + ? state.data.session.email + : t("session.signedOut")} + + ); +} diff --git a/apps/dashboard/src/features/manifest-draft/ManifestSaveControl.tsx b/apps/dashboard/src/features/manifest-draft/ManifestSaveControl.tsx new file mode 100644 index 00000000..281942a8 --- /dev/null +++ b/apps/dashboard/src/features/manifest-draft/ManifestSaveControl.tsx @@ -0,0 +1,277 @@ +import { FilePenLine, FileDiff, Save, Trash2, RefreshCw } from "lucide-react"; +import type React from "react"; +import { useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSWRConfig } from "swr"; +import { applyManifestDraft, previewManifestDraft } from "@/api/manifest"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { ErrorNotice } from "@/components/ui/page-layout"; +import { Spinner } from "@/components/ui/spinner"; +import { + manifestDraftKey, + useManifestDraftStore, +} from "@/features/manifest-draft/manifest-draft-store"; +import { + sideBySideDiffRows, + type UnifiedDiffLine, + unifiedFileDiff, +} from "@/features/manifest-draft/unified-diff"; +import { useToast } from "@/hooks/useToast"; +import type { ApplyManifestRequest, HttpError, PreviewManifestResponse } from "@/types/api"; +export const ManifestSaveControl: React.FC<{ entryId: string }> = ({ entryId }) => { + const { t } = useTranslation(); + const { mutate } = useSWRConfig(); + const toast = useToast(); + const draft = useManifestDraftStore((state) => state.drafts[manifestDraftKey(entryId)]); + const clearWorkspace = useManifestDraftStore((state) => state.clearWorkspace); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [preview, setPreview] = useState(); + const [error, setError] = useState(""); + const diffViewport = useRef(null); + const diffLines = useMemo( + () => (preview ? unifiedFileDiff(preview.before, preview.after) : []), + [preview], + ); + const diffRows = useMemo(() => sideBySideDiffRows(diffLines), [diffLines]); + + if (!draft) return null; + const changedCount = draft.summaries.filter((summary) => summary.changed).length; + + async function showPreview() { + if (!draft || previewing) return; + setOpen(true); + setPreview(undefined); + setPreviewing(true); + setError(""); + try { + const result = await previewManifestDraft( + { + revision: draft.revision, + workspace: draft.workspace, + changes: Object.values(draft.changes), + }, + entryId, + ); + setPreview(result); + } catch (cause) { + const failure = cause as HttpError; + setError( + failure.code === "SERVE_MANIFEST_CONFLICT" + ? t("manifestDraft.conflict") + : failure.message || t("manifestDraft.previewFailed"), + ); + } finally { + setPreviewing(false); + } + } + + async function save() { + if (!draft || saving) return; + setSaving(true); + setError(""); + try { + const payload: ApplyManifestRequest = { + revision: draft.revision, + workspace: draft.workspace, + changes: Object.values(draft.changes), + }; + await applyManifestDraft(payload, entryId); + clearWorkspace(entryId); + setOpen(false); + await mutate( + (key) => typeof key === "string" && key.startsWith(`/workspaces/${entryId}/`), + undefined, + { revalidate: true }, + ); + toast.success(t("manifestDraft.saved")); + } catch (cause) { + const failure = cause as HttpError; + setError( + failure.code === "SERVE_MANIFEST_CONFLICT" + ? t("manifestDraft.conflict") + : failure.message || t("manifestDraft.saveFailed"), + ); + } finally { + setSaving(false); + } + } + + return ( + <> + + + !saving && setOpen(next)}> + + +
+
+ +
+
+ {t("manifestDraft.title")} + + {t("manifestDraft.description")} + +
+
+
+ +
+ {preview && ( + + )} + {draft.summaries + .filter((item) => item.changed) + .map((item) => ( + + {t(item.labelKey)} + + ))} +
+
+ {previewing ? ( +
+ + {t("manifestDraft.previewing")} +
+ ) : preview ? ( +
+
+
+ + {t("manifestDraft.currentManifest")} + + a/one.manifest.json +
+
+ + {t("manifestDraft.updatedManifest")} + + b/one.manifest.json +
+
+
+ {diffRows.map((row, index) => ( +
+ + +
+ ))} +
+
+ ) : null} +
+ + {error ? ( + void showPreview()} + > + + {t("secrets.retry")} + + ) : undefined + } + > + {error} + + ) : null} + + + +
+ {t("form.cancel")} + +
+
+
+
+ + ); +}; + +const ManifestDiffCell: React.FC<{ + line?: UnifiedDiffLine; + side: "before" | "after"; +}> = ({ line, side }) => { + const lineNumber = side === "before" ? line?.beforeLine : line?.afterLine; + const toneClass = !line + ? "bg-muted/30" + : line.kind === "removed" + ? "bg-error-surface text-error-foreground" + : line.kind === "added" + ? "bg-success-surface text-success-foreground" + : "text-foreground"; + const dividerClass = side === "before" ? "border-b border-border md:border-r md:border-b-0" : ""; + + return ( +
+ + {lineNumber} + + + {line?.kind === "removed" ? "-" : line?.kind === "added" ? "+" : " "} + + {line?.text} +
+ ); +}; 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 75b970fd..19b6fd32 100644 --- a/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts +++ b/apps/dashboard/src/features/manifest-draft/manifest-draft-store.ts @@ -1,7 +1,7 @@ import { createStore } from "@/lib/utils"; -import type { ProjectManifestPatch, ProfileValue, WorkspaceManifestPatch } from "@/types/api"; +import type { ProjectManifestPatch, JsonValue, WorkspaceManifestPatch } from "@/types/api"; -export type ManifestDraftSection = "general" | "environment" | "container" | "deploy"; +export type ManifestDraftSection = "general" | "environment"; type DraftValue = string | boolean | number | null | undefined; @@ -126,7 +126,7 @@ export const useManifestDraftStore = createStore( summaries.push(...summariesFor(project, section, initial, next, labels)); } - const hasProjectChange = ["general", "environment", "container", "deploy"].some( + const hasProjectChange = ["general", "environment"].some( (name) => projectPatch[name as ManifestDraftSection] !== undefined, ); if (hasProjectChange) changes[project] = projectPatch; @@ -216,5 +216,5 @@ export const useManifestDraftStore = createStore( 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 ProfileValue); + return String(value satisfies JsonValue); } diff --git a/apps/dashboard/src/features/profile-binding/ProfileBindingField.tsx b/apps/dashboard/src/features/profile-binding/ProfileBindingField.tsx deleted file mode 100644 index e40cd050..00000000 --- a/apps/dashboard/src/features/profile-binding/ProfileBindingField.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { AlertCircle, ExternalLink, Info } from "lucide-react"; -import type React from "react"; -import { useTranslation } from "react-i18next"; -import useSWR from "swr"; -import { useBackendCatalog } from "@/api/catalog"; -import { getSection, sectionKey } from "@/api/configure"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Field, FieldLabel } from "@/components/ui/field"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { cn } from "@/lib/utils"; -import type { BackendDomain, ProfileBinding } from "@/types/api"; - -type ProfileBindingScope = "project" | "workspace"; -const AUTOMATIC_PROFILE_VALUE = "__automatic_profile__"; - -function errorMessage(error: unknown): string { - if (error && typeof error === "object" && "message" in error) { - return String(error.message); - } - return String(error); -} - -export const ProfileBindingField: React.FC<{ - id: string; - scope: ProfileBindingScope; - directSource: string; - domain: BackendDomain; - backend?: string; - configurable?: boolean; - binding?: ProfileBinding; - value: string; - onChange(value: string): void; - disabled?: boolean; - variant?: "card" | "embedded"; - showDescription?: boolean; -}> = ({ - id, - scope, - directSource, - domain, - backend, - configurable, - binding, - value, - onChange, - disabled, - variant = "card", - showDescription = false, -}) => { - const { t } = useTranslation(); - const catalog = useBackendCatalog(); - const spec = backend ? catalog.byID.get(`${domain}/${backend}`) : undefined; - const profileConfigurable = configurable ?? spec?.profile.configurable; - const key = backend && profileConfigurable ? sectionKey(domain, backend) : null; - const section = useSWR(key, () => getSection(domain, backend ?? "")); - const copyRoot = - scope === "workspace" ? "overview.workspaceEnv.profile" : "projectInspector.profile"; - const names = Array.from( - new Set([...Object.keys(section.data?.section.profiles ?? {}), ...(value ? [value] : [])]), - ).sort(); - const automaticLabel = - binding && binding.source !== directSource - ? t(`${copyRoot}.inherited`, { - name: binding.name, - source: binding.source, - }) - : t(`${copyRoot}.automatic`); - - if (!backend || profileConfigurable === false) { - return ( - - - - {t(`${copyRoot}.notRequired`)} - - - ); - } - - if (profileConfigurable === undefined && catalog.isLoading) { - return ( -
- - -
- ); - } - - if (profileConfigurable === undefined && catalog.error) { - return ( - - - - {t(`${copyRoot}.loadFailed`)} {errorMessage(catalog.error)} - - - ); - } - - return ( - -
- {t(`${copyRoot}.label`)} - {variant === "card" || showDescription ? ( -

- {t(`${copyRoot}.description`)} -

- ) : null} -
- - {section.error ? ( - - - - {t(`${copyRoot}.loadFailed`)} {errorMessage(section.error)} - - - ) : null} -
- {binding ? `${binding.name} · ${binding.source}` : t(`${copyRoot}.none`)} - - {t(`${copyRoot}.manage`)} - - -
-
- ); -}; diff --git a/apps/dashboard/src/features/profile-editor/ProfileEditorDialog.test.tsx b/apps/dashboard/src/features/profile-editor/ProfileEditorDialog.test.tsx deleted file mode 100644 index 0b27a06b..00000000 --- a/apps/dashboard/src/features/profile-editor/ProfileEditorDialog.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { MemoryRouter } from "react-router-dom"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { - ProfileEditorDialog, - type ProfileEditorTarget, -} from "@/features/profile-editor/ProfileEditorDialog"; -import i18n from "@/lib/i18n"; -import type { BackendSpec } from "@/types/api"; - -const server = setupServer(); - -const vercelBackend: BackendSpec = { - id: "deploy/vercel", - domain: "deploy", - name: "vercel", - capabilities: ["deploy"], - profile: { - configurable: true, - fields: [ - { path: "team", input_name: "team", type: "string", label_key: "form.fields.teamSlug" }, - { - path: "credentials/apiToken", - input_name: "token", - type: "secret", - label_key: "form.fields.apiToken", - required: true, - }, - ], - }, -}; - -describe("profile editor dialog", () => { - beforeAll(async () => { - server.listen({ onUnhandledRequest: "error" }); - await i18n.changeLanguage("en-US"); - }); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - - it("owns profile upsert and reports the saved result", async () => { - let requestBody: unknown; - server.use( - http.post("http://localhost/api/configure/deploy/vercel", async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - schema: "one-cli/serve-configure-upsert/v1", - status: "completed", - domain: "deploy", - backend: "vercel", - name: "production", - default: true, - }); - }), - ); - const onOpenChange = vi.fn(); - const onSaved = vi.fn(); - const target: ProfileEditorTarget = { - backend: vercelBackend, - name: "production", - profile: { team: "one-team", credentials: { apiToken: "" } }, - mode: "edit", - hasDefault: true, - }; - - render( - - - , - ); - - await userEvent.type(screen.getByLabelText("API Token"), "secret-token"); - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(requestBody).toEqual({ - name: "production", - profile: { team: "one-team", credentials: { apiToken: "secret-token" } }, - use: false, - }); - }); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(onSaved).toHaveBeenCalledWith( - expect.objectContaining({ name: "production", status: "completed" }), - ); - }); - - it("keeps a masked secret unchanged when the user leaves it blank", async () => { - let requestBody: unknown; - server.use( - http.post("http://localhost/api/configure/deploy/vercel", async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - schema: "one-cli/serve-configure-upsert/v1", - status: "updated", - domain: "deploy", - backend: "vercel", - name: "production", - default: true, - }); - }), - ); - - render( - - {}} - /> - , - ); - - const token = screen.getByLabelText("API Token") as HTMLInputElement; - expect(token.type).toBe("password"); - expect(token.value).toBe(""); - expect(token.placeholder).toBe("Leave blank to keep unchanged"); - expect(screen.queryByDisplayValue("********")).toBeNull(); - - await userEvent.click(screen.getByRole("button", { name: "Save" })); - await waitFor(() => { - expect(requestBody).toEqual({ - name: "production", - profile: { team: "one-team", credentials: { apiToken: "********" } }, - use: false, - }); - }); - }); - - it("sends the default-profile choice from the checkbox", async () => { - let requestBody: unknown; - server.use( - http.post("http://localhost/api/configure/deploy/vercel", async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - schema: "one-cli/serve-configure-upsert/v1", - status: "updated", - domain: "deploy", - backend: "vercel", - name: "production", - default: true, - }); - }), - ); - - render( - - {}} - /> - , - ); - - await userEvent.click(screen.getByLabelText("Set default after save")); - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(requestBody).toMatchObject({ name: "production", use: true }); - }); - }); -}); diff --git a/apps/dashboard/src/features/profile-editor/ProfileEditorDialog.tsx b/apps/dashboard/src/features/profile-editor/ProfileEditorDialog.tsx deleted file mode 100644 index c92329c0..00000000 --- a/apps/dashboard/src/features/profile-editor/ProfileEditorDialog.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import { Save } from "lucide-react"; -import type React from "react"; -import { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { humanizeBackendName } from "@/api/catalog"; -import { upsertProfile } from "@/api/configure"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Field, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { useToast } from "@/hooks/useToast"; -import type { - AnyProfile, - BackendFieldSpec, - BackendSpec, - ProfileValue, - UpsertResponse, -} from "@/types/api"; - -const MASKED_SECRET = "********"; - -export interface ProfileEditorTarget { - backend: BackendSpec; - name: string; - profile: AnyProfile; - mode: "add" | "edit"; - hasDefault: boolean; -} - -interface ProfileEditorDialogProps { - target: ProfileEditorTarget | null; - onOpenChange(open: boolean): void; - onSaved?(result: UpsertResponse): void; -} - -// ProfileEditorDialog owns the complete upsert workflow shared by routed -// profile management and the workspace repair flow. The last target remains -// available while Radix animates a closing dialog, avoiding an empty frame. -export const ProfileEditorDialog: React.FC = ({ - target, - onOpenChange, - onSaved, -}) => { - const toast = useToast(); - const { t } = useTranslation(); - const lastTarget = useRef(null); - if (target) lastTarget.current = target; - const snapshot = target ?? lastTarget.current; - - async function handleSubmit(name: string, profile: AnyProfile, use: boolean) { - if (!snapshot) return; - try { - const result = await upsertProfile(snapshot.backend.domain, snapshot.backend.name, { - name, - profile, - use, - }); - toast.success( - result.status === "updated" ? t("toast.updated", { name }) : t("toast.created", { name }), - { description: result.default ? t("toast.setDefaultAfterSaveHint") : undefined }, - ); - onOpenChange(false); - onSaved?.(result); - } catch (error) { - const failure = error as { code?: string; message: string }; - toast.error(failure.message, { description: failure.code }); - } - } - - return ( - - - {snapshot ? ( - onOpenChange(false)} - onSubmit={handleSubmit} - /> - ) : null} - - - ); -}; - -export function emptyProfile(backend: BackendSpec): AnyProfile { - let profile: AnyProfile = {}; - for (const field of backend.profile.fields ?? []) { - const value = field.default ?? (field.type === "boolean" ? false : ""); - profile = setPath(profile, field.path, value); - } - return profile; -} - -interface ProfileFormProps { - backend: BackendSpec; - initialName: string; - initialProfile: AnyProfile; - mode: "add" | "edit"; - hasDefault: boolean; - onCancel(): void; - onSubmit(name: string, profile: AnyProfile, use: boolean): Promise; -} - -const ProfileForm: React.FC = ({ - backend, - initialName, - initialProfile, - mode, - hasDefault, - onCancel, - onSubmit, -}) => { - const { t } = useTranslation(); - const [name, setName] = useState(initialName); - const [profile, setProfile] = useState(initialProfile); - const [use, setUse] = useState(false); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitting(true); - try { - await onSubmit(name.trim(), profile, use); - } finally { - setSubmitting(false); - } - } - - return ( - <> - - - {mode === "add" ? t("form.addTitle") : t("form.editTitle", { name: initialName })} - - - {mode === "add" ? t("form.addDescription") : t("form.editDescription")} - - -
-
- - {t("form.profileName")} - setName(event.target.value)} - placeholder={t("form.profileNamePlaceholder")} - disabled={mode === "edit"} - required - /> - - - - - -
- setUse(checked === true)} - /> - - {hasDefault ? t("form.setDefaultAfterSave") : t("form.setDefaultAfterSaveAuto")} - -
-
-
- - - - - -
- - ); -}; - -const FieldRow: React.FC = ({ children }) => ( - {children} -); - -interface BackendFieldsProps { - backend: BackendSpec; - profile: AnyProfile; - setProfile(profile: AnyProfile): void; -} - -const BackendFields: React.FC = ({ backend, profile, setProfile }) => { - const { t } = useTranslation(); - return (backend.profile.fields ?? []).map((field) => { - const value = getPath(profile, field.path); - const inputID = `profile-${backend.name}-${field.path.replaceAll("/", "-")}`; - const leaf = field.path.split("/").at(-1) ?? field.path; - const label = t(field.label_key, { defaultValue: humanizeBackendName(leaf) }); - if (field.type === "boolean") { - return ( - -
- - setProfile(setPath(profile, field.path, checked === true)) - } - /> - - {label} - -
-
- ); - } - - const maskedPlaceholder = t("form.fields.secretUnchangedPlaceholder"); - return ( - - {label} - setProfile(setPath(profile, field.path, event.target.value))} - required={field.required && value !== MASKED_SECRET} - /> - - ); - }); -}; - -export const ProfileSummary: React.FC<{ backend: BackendSpec; profile: AnyProfile }> = ({ - backend, - profile, -}) => { - const { t } = useTranslation(); - const values = (backend.profile.fields ?? []) - .filter((field) => field.type !== "secret") - .map((field) => [field, getPath(profile, field.path)] as const) - .filter(([, value]) => value !== undefined && value !== "" && value !== false) - .slice(0, 2); - if (values.length === 0) { - return {t("form.summary.notSet")}; - } - return ( - - {values.map(([field, value]) => `${summaryLabel(field)}: ${String(value)}`).join(" · ")} - - ); -}; - -function getPath(profile: AnyProfile, path: string): ProfileValue | undefined { - let current: ProfileValue | undefined = profile; - for (const part of path.split("/")) { - if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; - current = current[part]; - } - return current; -} - -function setPath(profile: AnyProfile, path: string, value: ProfileValue): AnyProfile { - const parts = path.split("/"); - const root: AnyProfile = { ...profile }; - let current = root; - for (const [index, part] of parts.entries()) { - if (index === parts.length - 1) { - current[part] = value; - break; - } - const existing = current[part]; - const child: AnyProfile = - existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {}; - current[part] = child; - current = child; - } - return root; -} - -function secretInputValue(value: ProfileValue | undefined): string { - return value === MASKED_SECRET ? "" : typeof value === "string" ? value : ""; -} - -function secretPlaceholder( - value: ProfileValue | undefined, - placeholder: string, -): string | undefined { - return value === MASKED_SECRET ? placeholder : undefined; -} - -function summaryLabel(field: BackendFieldSpec): string { - return humanizeBackendName(field.path.split("/").at(-1) ?? field.path); -} diff --git a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx index 77015580..2468abef 100644 --- a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx @@ -1,21 +1,14 @@ import { - Boxes, - CloudUpload, - Code2, - KeyRound, - Library, - MoonStar, - Settings2, - SunMedium, -} from "lucide-react"; + manifestDraftKey, + useManifestDraftStore, +} from "@/features/manifest-draft/manifest-draft-store"; +import { Server, Code2, FileKey2, Library, Search, LayoutGrid, LockKeyhole } from "lucide-react"; import type React from "react"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; -import { backendRequiresContainerArtifact, useBackendCatalog } from "@/api/catalog"; import { getProjectSettings, projectSettingsKey } from "@/api/workspace"; -import { LanguageSwitcher } from "@/components/LanguageSwitcher"; -import { ManifestSaveControl } from "@/components/TopBar"; +import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertDialog, @@ -29,22 +22,22 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; import { EnvironmentSelector } from "@/features/environment-context/EnvironmentSelector"; import { useEnvironmentDirtyStore } from "@/features/environment-context/environment-dirty-store"; -import { - manifestDraftKey, - useManifestDraftStore, -} from "@/features/manifest-draft/manifest-draft-store"; -import { ContainerForm } from "@/features/project-settings/forms/ContainerForm"; -import { DeployForm } from "@/features/project-settings/forms/DeployForm"; import { EnvironmentForm } from "@/features/project-settings/forms/EnvironmentForm"; import { GeneralForm } from "@/features/project-settings/forms/GeneralForm"; import type { ProjectInspectorTab } from "@/features/project-settings/ProjectMatrix"; import { WorkspaceSettingsDialog } from "@/features/workspace-settings/WorkspaceSettingsDialog"; -import { useThemeStore } from "@/lib/stores/theme"; import { cn } from "@/lib/utils"; import type { OverviewProject, ProjectSettingsResponse } from "@/types/api"; @@ -60,9 +53,8 @@ const TAB_ITEMS: ReadonlyArray<{ id: ProjectInspectorTab; icon: React.ComponentType<{ className?: string }>; }> = [ - { id: "overview", icon: Settings2 }, - { id: "environment", icon: KeyRound }, - { id: "deploy", icon: CloudUpload }, + { id: "overview", icon: LayoutGrid }, + { id: "environment", icon: FileKey2 }, ]; export const ProjectInspector: React.FC = ({ @@ -73,15 +65,14 @@ export const ProjectInspector: React.FC = ({ readOnly, }) => { const { t } = useTranslation(); - const { mode, toggle } = useThemeStore(); const dirtyOwner = useId(); + const [query, setQuery] = useState(""); const [dirty, setDirty] = useState(false); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); const [selectedName, setSelectedName] = useState(projects[0]?.name ?? ""); const setEnvironmentDirty = useEnvironmentDirtyStore((state) => state.setDirty); const clearEnvironmentDirty = useEnvironmentDirtyStore((state) => state.clearOwner); const selectedProject = projects.find((project) => project.name === selectedName) ?? projects[0]; - const logoSrc = mode === "dark" ? "/brand/icon-inverted.svg" : "/brand/icon.svg"; function setInspectorDirty(next: boolean) { setDirty(next); @@ -103,114 +94,149 @@ export const ProjectInspector: React.FC = ({ setPendingAction(() => action); } + const filteredProjects = projects.filter((project) => + `${project.name} ${project.relativeDir} ${project.domains?.env ?? currentBackend ?? ""}` + .toLocaleLowerCase() + .includes(query.trim().toLocaleLowerCase()), + ); + function selectProject(name: string) { + if (name === selectedProject?.name) return; + requestDiscard(() => { + setInspectorDirty(false); + setSelectedName(name); + }); + } + return ( <>
-
@@ -255,7 +281,7 @@ const PROJECT_KIND_ICON: Record< React.ComponentType<{ className?: string }> > = { app: Code2, - service: Boxes, + service: Server, package: Library, }; @@ -280,10 +306,10 @@ const InspectorBody: React.FC<{ const [activeTab, setActiveTab] = useState(initialTab); const key = projectSettingsKey(project.name, workspaceEntryId, environment); const result = useSWR(key, () => getProjectSettings(project.name, workspaceEntryId, environment)); + const draft = useManifestDraftStore((state) => state.drafts[manifestDraftKey(workspaceEntryId)]); const sectionTitle = t( `projectInspector.${activeTab === "overview" ? "general" : activeTab}.title`, ); - const isManifestDraftSection = activeTab !== "deploy"; return ( -
-
+
+
+

{project.name}

-

{sectionTitle}

- {isManifestDraftSection ? ( - - {t("projectInspector.manifestDraft")} + {readOnly ? ( + + + {t("projectInspector.manifestReadOnly")} + ) : draft ? ( + {t("projectInspector.manifestDraft")} + ) : null} + {workspaceEntryId && !readOnly ? ( + ) : null}
-
- - {TAB_ITEMS.map(({ id, icon: Icon }) => ( - - - {t(`projectInspector.tabs.${id}`)} - - ))} - - {workspaceEntryId ? : null} -
+

{sectionTitle}

+ + {TAB_ITEMS.map(({ id, icon: Icon }) => ( + + + {t(`projectInspector.tabs.${id}`)} + + ))} +
-
+
{TAB_ITEMS.map(({ id }) => ( - + {result.isLoading ? : null} {result.error ? void result.mutate()} /> : null} {result.data ? ( @@ -356,13 +382,16 @@ const InspectorBody: React.FC<{ ); }; -const InspectorLoading: React.FC = () => ( -
- - - -
-); +const InspectorLoading: React.FC = () => { + const { t } = useTranslation(); + return ( +
+ + + +
+ ); +}; const InspectorError: React.FC<{ onRetry(): void }> = ({ onRetry }) => { const { t } = useTranslation(); @@ -414,7 +443,7 @@ const ProjectSettingsPanel: React.FC = ({ if (activeTab === "environment") { return ( = ({ /> ); } - return ( - - ); -}; - -type ProfileSection = "deploy" | "container"; - -const DeploymentSettingsPanel: React.FC< - Omit & { - project: ProjectSettingsResponse["project"]; - revision: string; - } -> = ({ project, revision, environment, workspaceEntryId, readOnly, onUpdated, onDirtyChange }) => { - const catalog = useBackendCatalog(); - const dirtySections = useRef>({ - deploy: false, - container: false, - }); - const [containerProfileDirty, setContainerProfileDirty] = useState(false); - const stagedDeploy = useManifestDraftStore( - (state) => state.drafts[manifestDraftKey(workspaceEntryId)]?.changes[project.name]?.deploy, - ); - const deployBackend = stagedDeploy?.backend ?? project.deploy.backend; - const requiresImage = backendRequiresContainerArtifact( - deployBackend ? catalog.byID.get(`deploy/${deployBackend}`) : undefined, - ); - - function setSectionDirty(section: ProfileSection, dirty: boolean) { - dirtySections.current[section] = dirty; - if (section === "container") setContainerProfileDirty(dirty); - onDirtyChange(dirtySections.current.deploy || dirtySections.current.container); - } - - function sectionUpdated(section: ProfileSection, next: ProjectSettingsResponse) { - setSectionDirty(section, false); - onUpdated(next); - } - - return ( - sectionUpdated("deploy", next)} - onDirtyChange={(dirty) => setSectionDirty("deploy", dirty)} - > - {requiresImage || containerProfileDirty ? ( - sectionUpdated("container", next)} - onDirtyChange={(dirty) => setSectionDirty("container", dirty)} - /> - ) : null} - - ); + return null; }; diff --git a/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx b/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx index 84634156..06e1050f 100644 --- a/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectMatrix.tsx @@ -2,7 +2,6 @@ import { AlertCircle, Boxes, CheckCircle2, - CloudUpload, Code2, KeyRound, Library, @@ -46,9 +45,9 @@ import type { OverviewProjectKind, } from "@/types/api"; -export type ProjectInspectorTab = "overview" | "environment" | "deploy"; +export type ProjectInspectorTab = "overview" | "environment"; -type MatrixDomain = Exclude; +type MatrixDomain = OverviewIssueDomain; interface ProjectMatrixProps { projects: OverviewProject[]; @@ -70,12 +69,6 @@ function issueFor( return project.issues?.find((issue) => issue.domain === domain); } -function domainIsApplicable(project: OverviewProject, domain: MatrixDomain): boolean { - if (domain === "env") return true; - if (project.kind === "package") return false; - return (project.compatibleDeployTargets?.length ?? 0) > 0; -} - function projectSearchText(project: OverviewProject): string { return [ project.name, @@ -90,11 +83,6 @@ function projectSearchText(project: OverviewProject): string { .toLowerCase(); } -function inspectorTabForIssue(issue: OverviewIssue): ProjectInspectorTab { - if (issue.domain === "env") return "environment"; - return "deploy"; -} - interface DomainCellProps { project: OverviewProject; domain: MatrixDomain; @@ -105,26 +93,13 @@ interface DomainCellProps { const DOMAIN_ICON: Record> = { env: KeyRound, - deploy: CloudUpload, }; const DomainCell: React.FC = ({ project, domain, backend, readOnly, onClick }) => { const { t } = useTranslation(); const issue = issueFor(project, domain); - const applicable = domainIsApplicable(project, domain); const Icon = DOMAIN_ICON[domain]; - if (!applicable) { - return ( - - {t("projects.matrix.notApplicable")} - - ); - } - return ( + + ) : null} + ) : null} void saveEditor()} @@ -412,7 +522,15 @@ export const SecretsManager: React.FC<{ } }} > - + { + if (actionTrigger.current?.isConnected) { + event.preventDefault(); + actionTrigger.current.focus(); + actionTrigger.current = null; + } + }} + > {t("secrets.deleteTitle", { key: deleteKey })} {t("secrets.deleteDescription")} @@ -448,64 +566,133 @@ export const SecretsManager: React.FC<{ const SecretEditor: React.FC<{ editor: SecretEditorState | null; saving: boolean; + error: string; + returnFocus: HTMLButtonElement | null; onChange(editor: SecretEditorState): void; onSave(): void; onClose(): void; -}> = ({ editor, saving, onChange, onSave, onClose }) => { +}> = ({ editor, saving, error, returnFocus, onChange, onSave, onClose }) => { const { t } = useTranslation(); const [showValue, setShowValue] = useState(false); - useEffect(() => setShowValue(false), [editor?.key, editor?.mode]); + const [confirmClose, setConfirmClose] = useState(false); + const isOpen = Boolean(editor); + useEffect(() => { + setShowValue(false); + setConfirmClose(false); + }, [isOpen]); + const dirty = + editor && + (editor.mode === "create" + ? Boolean(editor.key || editor.value) + : editor.value !== editor.originalValue); + useEffect(() => { + if (!dirty) return; + const warn = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + function close() { + if (saving) return; + if (dirty) setConfirmClose(true); + else onClose(); + } return ( - !open && onClose()}> - + { + if (!open) close(); + }} + > + { + if (returnFocus?.isConnected) { + event.preventDefault(); + returnFocus.focus(); + } + }} + > {t(editor?.mode === "edit" ? "secrets.editTitle" : "secrets.createTitle")} {t("secrets.editorDescription")} - {editor ? ( -
- - {t("secrets.key")} - onChange({ ...editor, key: event.target.value.toUpperCase() })} - disabled={editor.mode === "edit" || saving} - autoComplete="off" - /> - - -
- {t("secrets.value")} - -
- onChange({ ...editor, value: event.target.value })} - disabled={saving} - autoComplete="new-password" - /> -
-
- ) : null} - - - - + + + + ) : ( + + + + + )} +
); diff --git a/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx b/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx index ccb91a12..b6d7e745 100644 --- a/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx +++ b/apps/dashboard/src/features/workspace-overview/WorkspaceActionCenter.tsx @@ -21,11 +21,6 @@ function issueKey(issue: OverviewIssue): string { ); } -function issueTab(issue: OverviewIssue): ProjectInspectorTab { - if (issue.domain === "env") return "environment"; - return "deploy"; -} - function profileIssueSettingsPath(issue: OverviewIssue): string { const section = issue.section ?? (issue.backend ? `${issue.domain}/${issue.backend}` : ""); const [domain, backend, extra] = section.split("/"); @@ -139,7 +134,7 @@ export const WorkspaceActionCenter: React.FC = ({ variant="ghost" className="text-primary" disabled={readOnly} - onClick={() => onInspect(project, issueTab(issue))} + onClick={() => onInspect(project, "environment")} > {t("overview.actionCenter.resolve")} diff --git a/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx b/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx index 7575ff9f..ee69cc2a 100644 --- a/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx +++ b/apps/dashboard/src/features/workspace-registry/WorkspaceRail.tsx @@ -26,11 +26,11 @@ import { cn } from "@/lib/utils"; import type { WorkspaceRegistryEntry, WorkspaceRegistryStatus } from "@/types/api"; const STATUS_DOT_CLASS: Record = { - ready: "bg-success-500", - missing: "bg-gray-400", - invalid: "bg-error-500", - "identity-missing": "bg-warning-500", - "identity-conflict": "bg-warning-500", + ready: "bg-success-foreground", + missing: "bg-muted-foreground", + invalid: "bg-error-foreground", + "identity-missing": "bg-warning-foreground", + "identity-conflict": "bg-warning-foreground", }; const WorkspaceRailItem: React.FC<{ @@ -44,14 +44,14 @@ const WorkspaceRailItem: React.FC<{ return (
{active ? : null} @@ -63,14 +63,14 @@ const WorkspaceRailItem: React.FC<{ /> {workspace.name} {workspace.projectCount} @@ -81,9 +81,9 @@ const WorkspaceRailItem: React.FC<{ + } + > + {message(settings.error || projects.error)} + + ) : null} + ); -}; +} diff --git a/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx b/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx index 6ca35d46..ac3c57ea 100644 --- a/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx +++ b/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx @@ -1,3 +1,4 @@ +import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { Braces, KeyRound, Settings } from "lucide-react"; import type React from "react"; import { useState } from "react"; @@ -8,6 +9,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -41,7 +43,7 @@ export const WorkspaceSettingsDialog: React.FC<{ - - + + {t("overview.navigation.settings")} {t("overview.workspaceEnv.description")} @@ -59,17 +61,24 @@ export const WorkspaceSettingsDialog: React.FC<{ onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col gap-0" > - - - - {t("overview.tabs.environment")} - - - - {t("overview.tabs.secrets")} - - -
+
{ + event.target.scrollIntoView({ block: "nearest", inline: "nearest" }); + }} + > + + + + {t("overview.tabs.environment")} + + + + {t("overview.tabs.secrets")} + + +
+
+ + + {workspaceEntryId && !readOnly && } +
); diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index ee7ca8f1..fcfaf7eb 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -8,7 +8,10 @@ "language": "Language", "languageAuto": "Follow system", "languageZh": "中文", - "languageEn": "English" + "languageEn": "English", + "navigation": "Navigation", + "openNavigation": "Open navigation", + "closeNavigation": "Close navigation" }, "topbar": { "home": "Workbench", @@ -35,7 +38,9 @@ "discard": "Discard draft", "saved": "Manifest changes saved", "saveFailed": "Could not save manifest changes", - "conflict": "one.manifest.json changed after this draft was opened. Discard the draft, review the latest configuration, and try again." + "conflict": "one.manifest.json changed after this draft was opened. Discard the draft, review the latest configuration, and try again.", + "changedFields": "Changed fields", + "jumpToChange": "Go to first change" }, "environmentSwitcher": { "label": "Environment", @@ -47,7 +52,7 @@ "workspaceLabel": "Workspace", "loading": "Loading workspace", "retry": "Retry", - "openProfiles": "Open credential profiles", + "openProfiles": "Open settings", "home": { "title": "Workspaces", "description": "Open a registered Workspace and manage its projects.", @@ -68,7 +73,18 @@ "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." + "registrationHint": "New Workspaces appear here after you run one serve.", + "refresh": "Refresh", + "search": "Search name, path or ID…", + "clearSearch": "Clear search", + "filterLabel": "Filter workspaces", + "all": "All workspaces", + "attention": "Needs attention", + "noResults": "No matching workspaces", + "noResultsDescription": "Try another name or path, or clear your filters.", + "clearFilters": "Clear filters", + "resultCount": "{{count}} matching workspaces", + "registrationHelp": "How to add a workspace" }, "rail": { "title": "Workspaces", @@ -107,7 +123,7 @@ "forget": { "short": "Remove Workspace", "action": "Remove {{name}}", - "confirm": "Remove Workspace \"{{name}}\"? This only removes the local registry entry; no project files or Profiles will be deleted.", + "confirm": "Remove Workspace \"{{name}}\"? This only removes the local registry entry; no project files or remote variables will be deleted.", "done": "Removed {{name}}", "failed": "Could not remove Workspace", "pageHint": "Use the trash action beside this Workspace in the left rail to remove only its local registry entry." @@ -122,7 +138,7 @@ }, "unknown": { "title": "Workspace not found", - "description": "This registry entry no longer exists. Choose another Workspace from the left rail.", + "description": "This registry entry no longer exists. Return home to choose another workspace.", "back": "Choose a Workspace" }, "noneReady": { @@ -131,7 +147,9 @@ } }, "notFound": { - "message": "404 - Page not found" + "message": "404 - Page not found", + "title": "Page not found", + "description": "This address does not match a dashboard page. Return home to continue." }, "errorBoundary": { "title": "The application hit an error", @@ -149,63 +167,13 @@ }, "sections": { "groupLabel": { - "env": "Environment variables", - "deploy": "Deploy", - "container": "Container" + "env": "Environment variables" }, "env": { "infisical": { "title": "Infisical", "description": "Universal Auth credentials and Infisical site URL for the env domain." } - }, - "deploy": { - "aliyun-oss": { - "title": "Aliyun OSS", - "description": "Aliyun OSS endpoint plus AK/SK using the S3 protocol." - }, - "tencent-cos": { - "title": "Tencent COS", - "description": "Tencent COS endpoint plus AK/SK using the S3 protocol." - }, - "aws-s3": { - "title": "AWS S3", - "description": "AWS S3 region plus AK/SK; leave endpoint blank for SDK defaults." - }, - "minio": { - "title": "MinIO", - "description": "Self-hosted MinIO object storage with path-style addressing." - }, - "rustfs": { - "title": "RustFS", - "description": "Self-hosted RustFS object storage with path-style addressing." - }, - "r2": { - "title": "Cloudflare R2", - "description": "Cloudflare R2 endpoint plus AK/SK." - }, - "kustomize": { - "title": "Kustomize", - "description": "Kubeconfig context for the deploy domain." - }, - "vercel": { - "title": "Vercel", - "description": "Vercel API token with optional team slug." - }, - "cloudflare": { - "title": "Cloudflare", - "description": "Cloudflare API token with optional account ID." - }, - "edgeone": { - "title": "EdgeOne Pages", - "description": "Tencent EdgeOne Pages token with optional region." - } - }, - "container": { - "docker": { - "title": "Docker Registry", - "description": "ACR, Docker Hub, GHCR, Harbor, or another container registry." - } } }, "home": { @@ -296,7 +264,11 @@ "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}}", @@ -331,7 +303,6 @@ "matrix": { "project": "Project", "environment": "Environment", - "deploy": "Deploy", "status": "Status", "notApplicable": "-", "notApplicableTitle": "This configuration does not apply to the project", @@ -354,8 +325,7 @@ "tabs": { "label": "Project configuration sections", "overview": "Overview", - "environment": "Environment", - "deploy": "Deploy" + "environment": "Environment" }, "loadFailed": "Unable to load project configuration", "retry": "Retry", @@ -380,7 +350,14 @@ "buildVersion": "Build version", "packageManager": "Package manager", "packageManagerAutomatic": "Detect automatically", - "devCommand": "Development command" + "devCommand": "Development command", + "buildCommand": "Build command", + "buildSource": "one build reads {{source}}. Edit that file to change the build task.", + "buildMissing": "No build task configured", + "buildUnavailable": "Unable to read build configuration", + "buildUnsupported": "No build task is available for this project.", + "metadata": "Project information", + "runtime": "Development and build" }, "environment": { "title": "Environment configuration", @@ -392,17 +369,6 @@ "disabledHint": "Do not inject environment variables into project commands.", "keys": "Declared variable names" }, - "container": { - "title": "Image configuration", - "enabled": "Build an image for deployment", - "enabledHint": "Build and push this project's image with one container before deployment.", - "image": "Image address", - "namespace": "Image namespace" - }, - "deploy": { - "title": "Deployment configuration", - "notSupported": "This project's Template does not support a deployment target." - }, "profile": { "label": "Project profile", "description": "Choose machine-level credentials scoped to this project and environment.", @@ -413,7 +379,8 @@ "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." }, "secrets": { "title": "Infisical secrets", @@ -448,7 +415,15 @@ "deleteFailed": "Could not delete the secret", "deleteTitle": "Delete {{key}}?", "deleteDescription": "This removes the value from the selected Infisical environment and folder. This action cannot be undone here.", - "deleteConfirmation": "Type {{key}} to confirm" + "deleteConfirmation": "Type {{key}} to confirm", + "notConfiguredTitle": "Connect a storage project", + "notConfiguredHint": "Choose a storage project in Workspace settings, or connect automatically to load this environment’s secrets.", + "connectAndLoad": "Connect and load secrets", + "search": "Search secret keys", + "noMatches": "No matching secrets", + "scopeHint": "Changes here are saved directly to Infisical.", + "loadError": "Unable to load secrets", + "more": "More actions for {{key}}" }, "project": { "fields": { @@ -472,7 +447,7 @@ "title": "Workspace environment", "scope": "Workspace scope", "readOnly": "Read-only", - "description": "The Backend is written to the workspace manifest; Profile selections are saved automatically to this machine.", + "description": "Configure workspace environment storage and manage shared secrets.", "localBinding": "Machine-local Profile binding", "manifestLegend": "Shared manifest", "localLegend": "This machine", @@ -502,7 +477,9 @@ "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", @@ -554,15 +531,11 @@ }, "issue": { "label": { - "container": "container", - "deploy": "deploy", "env": "env", "profile": "profile" }, "missingProfile": "{{section}} is missing a usable credential profile{{profile}}. Fill in the API key / token.", "missing": { - "container": "No container backend is configured. `one container build` will fail.", - "deploy": "No deploy backend is configured. `one deploy` will fail.", "env": "Workspace has no env backend selected. Set one before using `one env`." } }, @@ -570,5 +543,80 @@ "profileCta": "Add credentials", "cliHint": "Configure this Backend with One CLI." } + }, + "session": { + "description": "CLI and Dashboard share one Infisical session.", + "loading": "Loading…", + "organization": "Organization", + "logout": "Log out", + "expired": "Your session expired. Sign in again.", + "signedOut": "Not signed in to Infisical", + "waiting": "Waiting for browser login…", + "reopen": "Reopen login page", + "cancel": "Cancel", + "login": "Sign in with browser", + "custom": "Use a custom instance", + "site": "Instance URL", + "language": "Display language", + "unavailable": "Session unavailable", + "settingsHint": "Manage your connected account and dashboard preferences.", + "connected": "Connected", + "account": "Account", + "languageHint": "Choose a language or follow your system setting." + }, + "global": { + "title": "Shared credentials", + "description": "Manage credentials and variables shared across projects.", + "loginRequired": "Sign in to Infisical to manage shared credentials.", + "location": "Default storage location", + "mismatch": "The saved location belongs to another account or instance. Select a project again.", + "locationHint": "Select or create a storage project. Only its location is saved here; credential values stay in Infisical.", + "project": "Storage project", + "selectProject": "Select a project", + "defaultEnv": "Default browsing environment", + "selectEnv": "Select an environment", + "noProjects": "No credential projects available. Use the default location or create a project.", + "saveLocation": "Save location", + "browseHint": "Browsing another environment does not change the CLI default.", + "environment": "Browsing environment", + "parent": "Parent folder", + "refresh": "Refresh", + "folders": "Folders", + "addFolder": "New folder", + "search": "Search variable names", + "add": "Add variable", + "key": "Name", + "value": "Value", + "actions": "Actions", + "hide": "Hide", + "reveal": "Reveal", + "copy": "Copy", + "edit": "Edit", + "delete": "Delete", + "empty": "No variables in this folder.", + "newValue": "New value", + "saveRemote": "Save to Infisical", + "discard": "Discard changes", + "deleteHint": "Delete {{key}} from {{project}} / {{environment}} / {{path}}? This takes effect immediately.", + "folderName": "Folder name", + "defaultLocation": "Use the default location", + "defaultLocationHint": "Prepare the storage project and save its location. An existing project with this name will be reused.", + "useDefault": "Initialize default location", + "saving": "Saving…", + "createProject": "New project", + "createProjectHint": "Create a credential storage project in the current Infisical organization. It will be selected so you can save it as your default location.", + "projectName": "Project name", + "createAndSelect": "Create and select", + "creatingProject": "Creating…", + "copied": "Value copied", + "saved": "Variable saved", + "folderCreated": "Folder created", + "noFolders": "No subfolders", + "count_one": "{{count}} variable", + "count_other": "{{count}} variables", + "more": "More actions for {{key}}", + "noMatches": "No matching variables", + "noMatchesHint": "Try another name or clear the search.", + "emptyHint": "Add a variable to share it across your projects." } } diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 38d6bf8a..218943a1 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -8,7 +8,10 @@ "language": "语言", "languageAuto": "跟随系统", "languageZh": "中文", - "languageEn": "English" + "languageEn": "English", + "navigation": "导航", + "openNavigation": "打开导航", + "closeNavigation": "关闭导航" }, "topbar": { "home": "工作台", @@ -35,7 +38,9 @@ "discard": "放弃草稿", "saved": "Manifest 修改已保存", "saveFailed": "无法保存 Manifest 修改", - "conflict": "草稿打开后 one.manifest.json 已发生变化。请放弃草稿、检查最新配置后再修改。" + "conflict": "草稿打开后 one.manifest.json 已发生变化。请放弃草稿、检查最新配置后再修改。", + "changedFields": "修改字段", + "jumpToChange": "定位首处修改" }, "environmentSwitcher": { "label": "环境", @@ -47,7 +52,7 @@ "workspaceLabel": "Workspace", "loading": "正在加载 Workspace", "retry": "重试", - "openProfiles": "打开凭据 Profile", + "openProfiles": "打开设置", "home": { "title": "工作区", "description": "查看本机已登记的工作区并继续管理项目。", @@ -68,10 +73,21 @@ "emptyDescription": "运行 one create 创建工作区,或在已有工作区中运行 one serve 进行登记。", "listLabel": "已登记的工作区", "registeredOnMachine": "已登记到本机", - "registrationHint": "运行 one serve 后,新的 Workspace 会显示在这里。" + "registrationHint": "运行 one serve 后,新的 Workspace 会显示在这里。", + "refresh": "刷新", + "search": "搜索名称、路径或 ID…", + "clearSearch": "清除搜索", + "filterLabel": "筛选工作区", + "all": "全部工作区", + "attention": "需要关注", + "noResults": "没有匹配的工作区", + "noResultsDescription": "试试其他名称或路径,或清除筛选条件。", + "clearFilters": "清除筛选", + "resultCount": "找到 {{count}} 个工作区", + "registrationHelp": "如何添加工作区" }, "rail": { - "title": "Workspaces", + "title": "工作区", "empty": "执行 one create,或在 Workspace 内执行 one serve,即可登记到这里。", "loadFailed": "无法加载 Workspace 注册表。" }, @@ -107,7 +123,7 @@ "forget": { "short": "移除 Workspace", "action": "移除 {{name}}", - "confirm": "要移除 Workspace「{{name}}」吗?这里只会移除本机注册记录,不会删除项目文件或 Profile。", + "confirm": "要移除 Workspace「{{name}}」吗?这里只会移除本机注册记录,不会删除项目文件或远端变量。", "done": "已移除 {{name}}", "failed": "无法移除 Workspace", "pageHint": "可点击左侧该 Workspace 旁的垃圾桶,只移除它的本机注册记录。" @@ -122,7 +138,7 @@ }, "unknown": { "title": "找不到 Workspace", - "description": "这条注册记录已不存在,请从左侧选择其他 Workspace。", + "description": "这条登记记录已不存在,请返回首页选择其他工作区。", "back": "选择 Workspace" }, "noneReady": { @@ -131,7 +147,9 @@ } }, "notFound": { - "message": "404 - 页面未找到" + "message": "404 - 页面未找到", + "title": "页面不存在", + "description": "当前地址没有对应的管理页面,请返回首页继续。" }, "errorBoundary": { "title": "应用程序遇到了一个错误", @@ -149,63 +167,13 @@ }, "sections": { "groupLabel": { - "env": "环境变量", - "deploy": "部署", - "container": "容器" + "env": "环境变量" }, "env": { "infisical": { "title": "Infisical", "description": "Universal Auth 凭据 + Infisical site URL(env 域)" } - }, - "deploy": { - "aliyun-oss": { - "title": "Aliyun OSS", - "description": "阿里云 OSS endpoint + AK/SK(deploy 域,S3 协议)" - }, - "tencent-cos": { - "title": "Tencent COS", - "description": "腾讯云 COS endpoint + AK/SK(deploy 域,S3 协议)" - }, - "aws-s3": { - "title": "AWS S3", - "description": "AWS S3 region + AK/SK(deploy 域;endpoint 留空走 SDK 默认)" - }, - "minio": { - "title": "MinIO", - "description": "MinIO 自部署对象存储(deploy 域,path-style 寻址)" - }, - "rustfs": { - "title": "RustFS", - "description": "RustFS 自部署对象存储(deploy 域,path-style 寻址)" - }, - "r2": { - "title": "Cloudflare R2", - "description": "Cloudflare R2 endpoint + AK/SK(deploy 域)" - }, - "kustomize": { - "title": "Kustomize", - "description": "kubeconfig context(deploy 域)" - }, - "vercel": { - "title": "Vercel", - "description": "Vercel API token + 可选 team slug(deploy 域)" - }, - "cloudflare": { - "title": "Cloudflare", - "description": "Cloudflare API token + 可选 account ID(deploy 域)" - }, - "edgeone": { - "title": "EdgeOne Pages", - "description": "Tencent EdgeOne Pages token + 可选 region(deploy 域)" - } - }, - "container": { - "docker": { - "title": "Docker Registry", - "description": "ACR / Docker Hub / GHCR / Harbor 等(container 域)" - } } }, "home": { @@ -296,7 +264,11 @@ "regionDefaultLabel": "(default)", "registryUnset": "(未填 registry)", "acrRegionUnset": "(未填 region)" - } + }, + "close": "关闭", + "discardTitle": "放弃未保存的修改?", + "discardDescription": "当前修改尚未保存。放弃后将关闭编辑窗口。", + "continueEditing": "继续编辑" }, "toast": { "updated": "已更新 {{name}}", @@ -331,7 +303,6 @@ "matrix": { "project": "项目", "environment": "Environment", - "deploy": "Deploy", "status": "状态", "notApplicable": "-", "notApplicableTitle": "这个项目不适用该配置", @@ -354,8 +325,7 @@ "tabs": { "label": "项目配置分区", "overview": "概览", - "environment": "环境", - "deploy": "部署" + "environment": "环境" }, "loadFailed": "无法加载项目配置", "retry": "重试", @@ -380,7 +350,14 @@ "buildVersion": "构建版本", "packageManager": "包管理器", "packageManagerAutomatic": "自动检测", - "devCommand": "开发命令" + "devCommand": "开发命令", + "buildCommand": "构建命令", + "buildSource": "one build 自动读取 {{source}},请在该文件中修改构建任务。", + "buildMissing": "未配置构建任务", + "buildUnavailable": "无法读取构建配置", + "buildUnsupported": "此项目没有可用的构建任务。", + "metadata": "项目信息", + "runtime": "开发与构建" }, "environment": { "title": "环境配置", @@ -392,17 +369,6 @@ "disabledHint": "运行项目命令时不注入环境变量。", "keys": "已声明变量名" }, - "container": { - "title": "镜像配置", - "enabled": "为部署构建镜像", - "enabledHint": "部署前使用 one container 构建并推送这个项目的镜像。", - "image": "镜像地址", - "namespace": "镜像命名空间" - }, - "deploy": { - "title": "部署配置", - "notSupported": "这个项目的 Template 不支持部署目标。" - }, "profile": { "label": "项目 Profile", "description": "选择仅对这个项目和当前环境生效的机器级凭据。", @@ -413,7 +379,8 @@ "inherited": "继承 {{name}}({{source}})", "none": "尚未解析到 Profile", "manage": "前往设置管理" - } + }, + "draftHint": "修改将暂存为草稿,预览并保存后生效。" }, "secrets": { "title": "Infisical 密钥", @@ -448,7 +415,15 @@ "deleteFailed": "无法删除密钥", "deleteTitle": "删除 {{key}}?", "deleteDescription": "这会从当前 Infisical 环境和 folder 中移除该值,无法在这里撤销。", - "deleteConfirmation": "输入 {{key}} 确认删除" + "deleteConfirmation": "输入 {{key}} 确认删除", + "notConfiguredTitle": "连接存储项目", + "notConfiguredHint": "在工作区设置中选择存储项目,或自动连接后加载当前环境的密钥。", + "connectAndLoad": "连接并加载密钥", + "search": "搜索密钥名称", + "noMatches": "没有匹配的密钥", + "scopeHint": "此处的修改会直接保存到 Infisical。", + "loadError": "无法加载密钥", + "more": "{{key}} 的更多操作" }, "project": { "fields": { @@ -472,7 +447,7 @@ "title": "工作区环境变量", "scope": "Workspace 级", "readOnly": "只读", - "description": "Backend 写入工作区 Manifest;Profile 选择后会自动保存到当前机器。", + "description": "配置工作区环境存储,并管理共享密钥。", "localBinding": "机器本地 Profile 绑定", "manifestLegend": "共享 Manifest", "localLegend": "本机配置", @@ -502,7 +477,9 @@ "inherited": "自动 · {{name}}({{source}})", "none": "当前未解析到 Profile", "manage": "前往设置管理" - } + }, + "signInHint": "请先在设置中登录 Infisical,再选择存储项目。", + "noProjects": "暂无可用的存储项目,可在共享凭据中创建。" }, "tabs": { "label": "工作区分区", @@ -554,15 +531,11 @@ }, "issue": { "label": { - "container": "container", - "deploy": "deploy", "env": "env", "profile": "profile" }, "missingProfile": "{{section}} 缺少可用凭据 profile{{profile}},请补齐 API key / token。", "missing": { - "container": "未配置 container,运行 `one container build` 会报错。", - "deploy": "未配置 deploy backend,运行 `one deploy` 会报错。", "env": "workspace 未选择 env backend,请先配置再运行 `one env` 相关命令。" } }, @@ -570,5 +543,80 @@ "profileCta": "补凭据", "cliHint": "请使用 One CLI 配置这个 Backend。" } + }, + "session": { + "description": "CLI 和 Dashboard 共用一个 Infisical 登录。", + "loading": "正在加载…", + "organization": "组织", + "logout": "退出登录", + "expired": "登录已过期,请重新登录。", + "signedOut": "未登录 Infisical", + "waiting": "等待浏览器完成登录…", + "reopen": "重新打开登录页面", + "cancel": "取消", + "login": "在浏览器中登录", + "custom": "使用自定义实例", + "site": "实例地址", + "language": "显示语言", + "unavailable": "登录状态暂不可用", + "settingsHint": "管理已连接的账号与界面偏好。", + "connected": "已连接", + "account": "账号", + "languageHint": "选择显示语言,或跟随系统设置。" + }, + "global": { + "title": "共享凭据", + "description": "集中管理跨项目使用的凭据与变量。", + "loginRequired": "登录 Infisical 后即可查看和管理共享凭据。", + "location": "默认存放位置", + "mismatch": "当前账号或实例与已保存的位置不匹配,请重新选择存放项目。", + "locationHint": "选择或新建存放项目。这里只保存位置,凭据值保存在 Infisical。", + "project": "存放项目", + "selectProject": "选择存放项目", + "defaultEnv": "默认浏览环境", + "selectEnv": "选择环境", + "noProjects": "暂无可用的凭据项目,可以使用默认位置或新建项目。", + "saveLocation": "保存位置", + "browseHint": "切换浏览环境不会修改 CLI 的默认环境。", + "environment": "浏览环境", + "parent": "上级目录", + "refresh": "刷新", + "folders": "目录", + "addFolder": "新建目录", + "search": "搜索变量名", + "add": "新增变量", + "key": "变量名", + "value": "变量值", + "actions": "操作", + "hide": "隐藏", + "reveal": "显示", + "copy": "复制", + "edit": "修改", + "delete": "删除", + "empty": "当前目录没有变量。", + "newValue": "新的变量值", + "saveRemote": "保存到 Infisical", + "discard": "放弃修改", + "deleteHint": "确认删除 {{project}} / {{environment}} / {{path}} 中的 {{key}}?此操作立即生效。", + "folderName": "目录名", + "defaultLocation": "使用默认位置", + "defaultLocationHint": "一键准备存放项目并保存位置;已有同名项目会直接复用。", + "useDefault": "初始化默认位置", + "saving": "正在保存…", + "createProject": "新建项目", + "createProjectHint": "在当前 Infisical 组织中创建用于存放凭据的项目。创建后选中它,再保存为默认位置。", + "projectName": "项目名称", + "createAndSelect": "创建并选中", + "creatingProject": "正在创建…", + "copied": "已复制变量值", + "saved": "变量已保存", + "folderCreated": "文件夹已创建", + "noFolders": "暂无子文件夹", + "count_one": "{{count}} 个变量", + "count_other": "{{count}} 个变量", + "more": "{{key}} 的更多操作", + "noMatches": "没有匹配的变量", + "noMatchesHint": "尝试其他名称,或清除搜索条件。", + "emptyHint": "添加变量,让多个项目共享使用。" } } diff --git a/apps/dashboard/src/pages/Overview.test.tsx b/apps/dashboard/src/pages/Overview.test.tsx index 8d7d3b71..da3115d4 100644 --- a/apps/dashboard/src/pages/Overview.test.tsx +++ b/apps/dashboard/src/pages/Overview.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; @@ -8,9 +8,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { getOverview, overviewKeyFor, - projectProfileBindingKey, projectSettingsKey, - workspaceProfileBindingKey, + workspaceEnvironmentKey, } from "@/api/workspace"; import { environmentFromSearch } from "@/features/environment-context/environment"; import { @@ -34,7 +33,6 @@ const catalogBackends: BackendSpec[] = [ domain: "env", name: "dotenv", capabilities: ["env-load"], - profile: { configurable: false }, project: { configurable: false }, }, { @@ -42,53 +40,8 @@ const catalogBackends: BackendSpec[] = [ domain: "env", name: "infisical", capabilities: ["env-load"], - profile: { configurable: true, fields: [] }, project: { configurable: false }, }, - { - id: "container/docker", - domain: "container", - name: "docker", - capabilities: ["container-build"], - profile: { configurable: true, fields: [] }, - project: { configurable: false }, - }, - { - id: "deploy/kustomize", - domain: "deploy", - name: "kustomize", - capabilities: ["deploy"], - requirements: [ - { kind: "capability", name: "container/build" }, - { kind: "capability", name: "container/push" }, - ], - profile: { configurable: true, fields: [] }, - project: { configurable: true, fields: [] }, - }, - { - id: "deploy/vercel", - domain: "deploy", - name: "vercel", - capabilities: ["deploy"], - profile: { configurable: true, fields: [] }, - project: { - configurable: true, - fields: [ - { - path: "projectName", - input_name: "project-name", - type: "string", - label_key: "project.fields.projectName", - }, - { - path: "env", - input_name: "environment", - type: "environment", - label_key: "project.fields.environment", - }, - ], - }, - }, ]; const overview: OverviewPayload = { @@ -110,8 +63,7 @@ const overview: OverviewPayload = { kind: "app", templateId: "react-spa", toolchain: "node", - compatibleDeployTargets: ["vercel"], - domains: { env: "dotenv", container: "docker", deploy: "vercel" }, + domains: { env: "dotenv" }, }, { name: "api", @@ -119,8 +71,7 @@ const overview: OverviewPayload = { kind: "service", templateId: "go-api", toolchain: "go", - compatibleDeployTargets: ["kustomize"], - domains: { env: "dotenv", container: "docker", deploy: "kustomize" }, + domains: { env: "dotenv" }, }, { name: "shared", @@ -147,6 +98,11 @@ const webSettings: ProjectSettingsResponse = { packageManager: "pnpm", buildVersion: "1.0.0", devCommand: "pnpm dev", + build: { + command: "pnpm run build", + source: "package.json#scripts.build", + status: "ready", + }, availableEnvironments: ["dev", "preview", "prod"], environment: { backend: "infisical", @@ -154,43 +110,6 @@ const webSettings: ProjectSettingsResponse = { inherits: true, disabled: false, keys: ["API_URL"], - selectedProfile: "work", - profile: { name: "work", source: "workspace-project-environment" }, - }, - container: { - enabled: true, - backend: "docker", - image: "ghcr.io/one/web:latest", - namespace: "one", - selectedProfile: "registry-main", - profile: { name: "registry-main", source: "workspace-project-environment" }, - }, - deploy: { - backend: "vercel", - compatibleTargets: ["vercel"], - config: { projectName: "old-web", env: "dev" }, - selectedProfile: "production", - profile: { name: "production", source: "workspace-project-environment" }, - }, - }, -}; - -const apiSettings: ProjectSettingsResponse = { - ...webSettings, - project: { - ...webSettings.project, - name: "api", - relativeDir: "services/api", - kind: "service", - templateId: "go-api", - toolchain: "go", - packageManager: undefined, - deploy: { - backend: "kustomize", - compatibleTargets: ["kustomize"], - config: { environment: "dev" }, - selectedProfile: "cluster-main", - profile: { name: "cluster-main", source: "workspace-project-environment" }, }, }, }; @@ -254,17 +173,13 @@ async function chooseSelect( await user.click(await screen.findByRole("option", { name: optionName })); } -function expectSelectText(trigger: HTMLElement, value: string) { - expect(trigger.textContent).toContain(value); -} - async function openProjectSettings() { return screen.findByRole("region", { name: "Project settings" }); } async function openProjectSettingsTab( user: ReturnType, - tabName: "Environment" | "Deploy", + tabName: "Environment", ) { const settings = await openProjectSettings(); await user.click(within(settings).getByRole("tab", { name: tabName })); @@ -282,16 +197,6 @@ async function openWorkspaceEnvironmentSettings(user: ReturnType, - currentName: string, - nextName: string, -) { - const selector = screen.getByRole("combobox", { name: `Environment: ${currentName}` }); - await user.click(selector); - await user.click(await screen.findByRole("option", { name: nextName })); -} - function sectionResponse(domain: BackendDomain, backend: string, profiles: string[]) { return { schema: "one-cli/serve-configure-section/v1", @@ -315,7 +220,7 @@ describe("workspace overview Profile-only configuration", () => { http.get("http://localhost/api/catalog", () => HttpResponse.json({ schema: "one-cli/catalog/v1", backends: catalogBackends }), ), - http.get("http://localhost/api/workspace/profile-bindings/env", ({ request }) => + http.get("http://localhost/api/workspace/environment", ({ request }) => HttpResponse.json({ schema: "one-cli/workspace-profile/v1", root: "/workspace/demo", @@ -323,10 +228,9 @@ describe("workspace overview Profile-only configuration", () => { domain: "env", backend: "dotenv", configurable: false, - selectedProfile: "", }), ), - http.get("http://localhost/api/workspaces/:entryId/profile-bindings/env", ({ request }) => + http.get("http://localhost/api/workspaces/:entryId/environment", ({ request }) => HttpResponse.json({ schema: "one-cli/workspace-profile/v1", root: "/workspace/demo", @@ -334,7 +238,6 @@ describe("workspace overview Profile-only configuration", () => { domain: "env", backend: "dotenv", configurable: false, - selectedProfile: "", }), ), http.get("http://localhost/api/workspace/secrets", () => @@ -393,10 +296,10 @@ describe("workspace overview Profile-only configuration", () => { expect( within(settings).getByRole("button", { name: "web apps/web" }).getAttribute("aria-current"), ).toBe("page"); - expect(await within(settings).findByText("Manifest draft")).toBeDefined(); + expect(within(settings).queryByText("Manifest draft")).toBeNull(); expect(within(settings).getByRole("tab", { name: "Overview" })).toBeDefined(); expect(within(settings).getByRole("tab", { name: "Environment" })).toBeDefined(); - expect(within(settings).getByRole("tab", { name: "Deploy" })).toBeDefined(); + expect(within(settings).queryByRole("tab", { name: "Deploy" })).toBeNull(); expect(within(settings).queryByRole("tab", { name: "Container" })).toBeNull(); }); @@ -428,101 +331,17 @@ describe("workspace overview Profile-only configuration", () => { it("uses environment-specific SWR keys for every workspace projection", () => { expect(overviewKeyFor("demo-entry", "dev")).toBe("/workspaces/demo-entry/overview?env=dev"); - expect(workspaceProfileBindingKey("demo-entry", "preview")).toBe( - "/workspaces/demo-entry/profile-bindings/env?env=preview", + expect(workspaceEnvironmentKey("demo-entry", "preview")).toBe( + "/workspaces/demo-entry/environment?env=preview", ); expect(projectSettingsKey("web app", "demo-entry", "prod")).toBe( "/workspaces/demo-entry/projects/web%20app?env=prod", ); - expect(projectProfileBindingKey("web", "deploy", undefined, "dev")).toBe( - "/workspace/projects/web/profile-bindings/deploy?env=dev", - ); expect(projectSettingsKey("web", undefined, "dev")).not.toBe( projectSettingsKey("web", undefined, "prod"), ); }); - it("exposes the workspace backend selector and saves Profile bindings separately", async () => { - let requestBody: unknown; - let receivedEnvironment = ""; - let legacyWrites = 0; - let overviewRequests = 0; - const configurableOverview: OverviewPayload = { - ...overview, - workspace: { - ...overview.workspace!, - domains: { ...overview.workspace?.domains, env: "infisical" }, - }, - issues: [ - { - domain: "env", - severity: "missing", - reason: "profile", - backend: "infisical", - section: "env/infisical", - message: "Infisical credentials are missing", - }, - ], - }; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/overview", ({ request }) => { - overviewRequests += 1; - expect(new URL(request.url).searchParams.get("env")).toBe("dev"); - return HttpResponse.json({ ...configurableOverview, issues: [] }); - }), - http.get("http://localhost/api/workspaces/demo-entry/profile-bindings/env", () => - HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile: "", - profile: { name: "work", source: "default" }, - }), - ), - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json(sectionResponse("env", "infisical", ["work", "personal"])), - ), - http.put( - "http://localhost/api/workspaces/demo-entry/profile-bindings/env", - async ({ request }) => { - requestBody = await request.json(); - const url = new URL(request.url); - receivedEnvironment = url.searchParams.get("env") ?? ""; - return HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile: "personal", - profile: { name: "personal", source: "workspace-environment" }, - }); - }, - ), - http.put("http://localhost/api/workspaces/demo-entry/domains/env", () => { - legacyWrites += 1; - return HttpResponse.json(configurableOverview); - }), - ); - const user = userEvent.setup(); - renderOverview(configurableOverview, "demo-entry", false, true); - - const region = await openWorkspaceEnvironmentSettings(user); - const backendSettings = within(region).getByTestId("workspace-backend-settings"); - expect(within(backendSettings).getByRole("combobox", { name: "Backend" })).toBeDefined(); - const profile = await within(backendSettings).findByRole("combobox", { name: "Profile" }); - await chooseSelect(user, profile, "personal"); - - await waitFor(() => expect(requestBody).toEqual({ profile: "personal" })); - expect(receivedEnvironment).toBe("dev"); - expect(legacyWrites).toBe(0); - await waitFor(() => expect(overviewRequests).toBe(1)); - }); - it("stages a Workspace env backend change for Manifest review", async () => { let backendWrites = 0; const configurableOverview: OverviewPayload = { @@ -533,7 +352,7 @@ describe("workspace overview Profile-only configuration", () => { }, }; server.use( - http.get("http://localhost/api/workspaces/demo-entry/profile-bindings/env", () => + http.get("http://localhost/api/workspaces/demo-entry/environment", () => HttpResponse.json({ schema: "one-cli/workspace-profile/v1", root: "/workspace/demo", @@ -542,8 +361,6 @@ describe("workspace overview Profile-only configuration", () => { domain: "env", backend: "infisical", configurable: true, - selectedProfile: "", - profile: { name: "work", source: "default" }, }), ), http.get("http://localhost/api/configure/env/infisical", () => @@ -562,7 +379,7 @@ describe("workspace overview Profile-only configuration", () => { renderOverview(configurableOverview, "demo-entry"); const region = await openWorkspaceEnvironmentSettings(user); - await chooseSelect(user, within(region).getByRole("combobox", { name: "Backend" }), "Dotenv"); + await chooseSelect(user, within(region).getByRole("combobox", { name: "Backend" }), "dotenv"); expect(useManifestDraftStore.getState().drafts[manifestDraftKey("demo-entry")]).toMatchObject({ revision: "sha256:test-revision", @@ -570,173 +387,11 @@ describe("workspace overview Profile-only configuration", () => { }); expect(within(region).getByText("Pending review")).toBeDefined(); expect(backendWrites).toBe(0); - }); - - it("unbinds a direct workspace Profile with an explicit empty value", async () => { - let requestBody: unknown; - const configurableOverview: OverviewPayload = { - ...overview, - workspace: { - ...overview.workspace!, - domains: { ...overview.workspace?.domains, env: "infisical" }, - }, - }; - server.use( - http.get("http://localhost/api/workspace/profile-bindings/env", () => - HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile: "personal", - profile: { name: "personal", source: "workspace-environment" }, - }), - ), - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json(sectionResponse("env", "infisical", ["work", "personal"])), - ), - http.put("http://localhost/api/workspace/profile-bindings/env", async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile: "", - profile: { name: "work", source: "default" }, - }); - }), - ); - const user = userEvent.setup(); - renderOverview(configurableOverview); - - const region = await openWorkspaceEnvironmentSettings(user); - const profile = await within(region).findByRole("combobox", { name: "Profile" }); - await waitFor(() => expectSelectText(profile, "personal")); - await chooseSelect(user, profile, "Resolve automatically (machine default)"); - await waitFor(() => expect(requestBody).toEqual({ profile: "" })); - }); - - it("auto-saves a Workspace Profile before changing environment", async () => { - const requestedEnvironments: string[] = []; - let requestBody: unknown; - const configurableOverview: OverviewPayload = { - ...overview, - workspace: { - ...overview.workspace!, - domains: { ...overview.workspace?.domains, env: "infisical" }, - }, - }; - server.use( - http.get("http://localhost/api/workspace/profile-bindings/env", ({ request }) => { - const selectedEnvironment = new URL(request.url).searchParams.get("env") ?? ""; - requestedEnvironments.push(selectedEnvironment); - const selectedProfile = selectedEnvironment === "preview" ? "preview-base" : "work"; - return HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: selectedEnvironment, - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile, - profile: { name: selectedProfile, source: "workspace-environment" }, - }); - }), - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json( - sectionResponse("env", "infisical", ["work", "personal", "preview-base"]), - ), - ), - http.put("http://localhost/api/workspace/profile-bindings/env", async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile: "personal", - profile: { name: "personal", source: "workspace-environment" }, - }); - }), - ); - const user = userEvent.setup(); - renderOverview(configurableOverview); - const region = await openWorkspaceEnvironmentSettings(user); - const profile = await within(region).findByRole("combobox", { name: "Profile" }); - await chooseSelect(user, profile, "personal"); - await waitFor(() => expect(requestBody).toEqual({ profile: "personal" })); - expectSelectText(profile, "personal"); - - const dialog = screen.getByRole("dialog", { name: "Workspace settings" }); - await user.click(within(dialog).getByRole("button", { name: "Close" })); - await selectEnvironment(user, "Development", "Preview"); - - await waitFor(() => - expect(screen.getByTestId("environment-search").textContent).toBe("?env=preview"), - ); - const previewRegion = await openWorkspaceEnvironmentSettings(user); - await waitFor(() => expect(requestedEnvironments).toContain("preview")); - await waitFor(() => - expectSelectText( - within(previewRegion).getByRole("combobox", { name: "Profile" }), - "preview-base", - ), - ); - }); - - it("keeps workspace Profile selection disabled for an identity conflict", async () => { - const configurableOverview: OverviewPayload = { - ...overview, - workspace: { - ...overview.workspace!, - domains: { ...overview.workspace?.domains, env: "infisical" }, - }, - }; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/profile-bindings/env", () => - HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: "dev", - domain: "env", - backend: "infisical", - configurable: true, - selectedProfile: "", - profile: { name: "work", source: "default" }, - }), - ), - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json(sectionResponse("env", "infisical", ["work"])), - ), - ); - const user = userEvent.setup(); - renderOverview(configurableOverview, "demo-entry", true); - - const region = await openWorkspaceEnvironmentSettings(user); expect( - (within(region).getByRole("combobox", { name: "Profile" }) as HTMLButtonElement).disabled, - ).toBe(true); - expect( - (within(region).getByRole("combobox", { name: "Backend" }) as HTMLButtonElement).disabled, - ).toBe(true); - expect(within(region).queryByRole("button", { name: "Save local binding" })).toBeNull(); - }); - - it("explains when the workspace backend does not use Profiles", async () => { - const user = userEvent.setup(); - renderOverview(); - const region = await openWorkspaceEnvironmentSettings(user); - expect( - within(region).getByText("This backend does not require a credential profile."), + within(screen.getByRole("dialog", { name: "Workspace settings" })).getByRole("button", { + name: "Save changes · 1", + }), ).toBeDefined(); - expect(within(region).queryByRole("combobox", { name: "Profile" })).toBeNull(); }); it("keeps identity fields read-only and stages editable General manifest fields", async () => { @@ -750,7 +405,8 @@ describe("workspace overview Profile-only configuration", () => { renderOverview(); const inspector = await openProjectSettings(); - expect(await within(inspector).findByText("Manifest draft")).toBeDefined(); + await within(inspector).findByLabelText("Build version"); + expect(within(inspector).queryByText("Manifest draft")).toBeNull(); expect((within(inspector).getByLabelText("Build version") as HTMLInputElement).value).toBe( "1.0.0", ); @@ -759,399 +415,59 @@ describe("workspace overview Profile-only configuration", () => { (within(inspector).getByLabelText("Development command") as HTMLInputElement).value, ).toBe("pnpm dev"); expect(within(inspector).queryByLabelText("Package manager")).toBeNull(); - expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); - expect(receivedEnvironment).toBe("dev"); - }); - - it("keeps project Environment settings manifest-only", async () => { - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Environment"); - const backendConfig = within(inspector).getByTestId("environment-settings-grid"); - - expect(within(backendConfig).queryByLabelText("Project profile")).toBeNull(); - expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); - }); - - it("auto-saves only {profile} through the deploy binding endpoint", async () => { - let requestBody: unknown; - let receivedEnvironment = ""; - let legacyWrites = 0; - server.use( - http.get("http://localhost/api/workspace/projects/web", () => HttpResponse.json(webSettings)), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production", "preview-team"])), - ), - http.put( - "http://localhost/api/workspace/projects/web/profile-bindings/deploy", - async ({ request }) => { - requestBody = await request.json(); - const url = new URL(request.url); - receivedEnvironment = url.searchParams.get("env") ?? ""; - return HttpResponse.json({ - ...webSettings, - project: { - ...webSettings.project, - deploy: { - ...webSettings.project.deploy, - selectedProfile: "preview-team", - profile: { - name: "preview-team", - source: "workspace-project-environment", - }, - }, - }, - }); - }, - ), - http.put("http://localhost/api/workspace/projects/web/settings/deploy", () => { - legacyWrites += 1; - return HttpResponse.json(webSettings); - }), - ); + const buildCommand = within(inspector).getByLabelText("Build command") as HTMLInputElement; + expect(buildCommand.value).toBe("pnpm run build"); + expect(buildCommand.readOnly).toBe(true); + expect(within(inspector).getByText(/one build reads package.json#scripts.build/)).toBeDefined(); const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Deploy"); - const backendConfig = within(inspector).getByTestId("deployment-settings-grid"); - expect(within(inspector).queryByText("Inherited deploy backend", { exact: false })).toBeNull(); - const profile = await within(backendConfig).findByLabelText("Project profile"); - expectSelectText(profile, "production"); - await chooseSelect(user, profile, "preview-team"); - - await waitFor(() => expect(requestBody).toEqual({ profile: "preview-team" })); - expect(Object.keys(requestBody as Record)).toEqual(["profile"]); - expect(receivedEnvironment).toBe("dev"); - expect(legacyWrites).toBe(0); - expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); - expect((within(inspector).getByLabelText("Project name") as HTMLInputElement).value).toBe( - "old-web", - ); - }); - - it("hides image configuration when the deploy backend does not require an image", async () => { - server.use( - http.get("http://localhost/api/workspace/projects/web", () => HttpResponse.json(webSettings)), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production"])), - ), - ); - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Deploy"); - + await user.clear(within(inspector).getByLabelText("Build version")); + await user.type(within(inspector).getByLabelText("Build version"), "2.0.0"); expect( - await within(inspector).findByRole("region", { name: "Deployment configuration" }), - ).toBeDefined(); - expect(within(inspector).queryByRole("region", { name: "Image configuration" })).toBeNull(); - }); - - it("shows image configuration for image-based deployment and saves its registry Profile", async () => { - let requestBody: unknown; - let receivedEnvironment = ""; - server.use( - http.get("http://localhost/api/workspace/projects/api", () => HttpResponse.json(apiSettings)), - http.get("http://localhost/api/configure/deploy/kustomize", () => - HttpResponse.json(sectionResponse("deploy", "kustomize", ["cluster-main"])), - ), - http.get("http://localhost/api/configure/container/docker", () => - HttpResponse.json( - sectionResponse("container", "docker", ["registry-main", "registry-backup"]), - ), - ), - http.put( - "http://localhost/api/workspace/projects/api/profile-bindings/container", - async ({ request }) => { - requestBody = await request.json(); - receivedEnvironment = new URL(request.url).searchParams.get("env") ?? ""; - return HttpResponse.json({ - ...apiSettings, - project: { - ...apiSettings.project, - container: { - ...apiSettings.project.container, - selectedProfile: "registry-backup", - profile: { - name: "registry-backup", - source: "workspace-project-environment", - }, - }, - }, - }); - }, - ), - ); - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettings(); - await user.click(within(inspector).getByRole("button", { name: "api services/api" })); - await user.click(within(inspector).getByRole("tab", { name: "Deploy" })); - - const imageForm = await within(inspector).findByRole("region", { - name: "Image configuration", - }); - expect( - within(imageForm).queryByText("Inherited container backend", { exact: false }), - ).toBeNull(); - const backendConfig = within(imageForm).getByTestId("image-settings-grid"); - const profile = await within(backendConfig).findByLabelText("Project profile"); - expectSelectText(profile, "registry-main"); - await chooseSelect(user, profile, "registry-backup"); - - await waitFor(() => expect(requestBody).toEqual({ profile: "registry-backup" })); + useManifestDraftStore.getState().drafts[manifestDraftKey()]?.changes.web?.general, + ).toEqual({ buildVersion: "2.0.0", devCommand: "pnpm dev" }); + expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); expect(receivedEnvironment).toBe("dev"); - expect(within(imageForm).queryByRole("button", { name: "Save local binding" })).toBeNull(); - }); - - it("reveals image configuration when the staged deploy backend starts requiring it", async () => { - const switchableSettings: ProjectSettingsResponse = { - ...webSettings, - project: { - ...webSettings.project, - deploy: { - ...webSettings.project.deploy, - compatibleTargets: ["vercel", "kustomize"], - }, - }, - }; - server.use( - http.get("http://localhost/api/workspace/projects/web", () => - HttpResponse.json(switchableSettings), - ), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production"])), - ), - http.get("http://localhost/api/configure/deploy/kustomize", () => - HttpResponse.json(sectionResponse("deploy", "kustomize", ["cluster-main"])), - ), - http.get("http://localhost/api/configure/container/docker", () => - HttpResponse.json(sectionResponse("container", "docker", ["registry-main"])), - ), - ); - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Deploy"); - const deploymentForm = await within(inspector).findByRole("region", { - name: "Deployment configuration", - }); - const backendConfig = within(deploymentForm).getByTestId("deployment-settings-grid"); - expect(within(inspector).queryByRole("region", { name: "Image configuration" })).toBeNull(); - expectSelectText(within(backendConfig).getByLabelText("Project profile"), "production"); - - await chooseSelect(user, within(deploymentForm).getByLabelText("Backend"), "Kustomize"); - - expect( - await within(inspector).findByRole("region", { name: "Image configuration" }), - ).toBeDefined(); - const profile = within(backendConfig).getAllByLabelText( - "Project profile", - )[0] as HTMLButtonElement; - expectSelectText(profile, "Resolve automatically (workspace / default)"); - expect(profile.disabled).toBe(true); - expect(within(deploymentForm).queryByRole("button", { name: "Save local binding" })).toBeNull(); - }); - - it("shows a stale Profile selection and can return it to Automatic", async () => { - let requestBody: unknown; - const staleSettings: ProjectSettingsResponse = { - ...webSettings, - project: { - ...webSettings.project, - deploy: { - ...webSettings.project.deploy, - selectedProfile: "deleted-profile", - profile: undefined, - }, - }, - }; - server.use( - http.get("http://localhost/api/workspace/projects/web", () => - HttpResponse.json(staleSettings), - ), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production"])), - ), - http.put( - "http://localhost/api/workspace/projects/web/profile-bindings/deploy", - async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - ...staleSettings, - project: { - ...staleSettings.project, - deploy: { - ...staleSettings.project.deploy, - selectedProfile: "", - profile: { name: "production", source: "default" }, - }, - }, - }); - }, - ), - ); - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Deploy"); - const profile = await within(inspector).findByLabelText("Project profile"); - expectSelectText(profile, "deleted-profile"); - - await chooseSelect(user, profile, "Resolve automatically (workspace / default)"); - - await waitFor(() => expect(requestBody).toEqual({ profile: "" })); - await waitFor(() => - expectSelectText(within(inspector).getByLabelText("Project profile"), "production"), - ); }); - it("auto-saves a project Profile before changing tabs or projects", async () => { - let requestBody: unknown; - server.use( - http.get("http://localhost/api/workspace/projects/web", () => HttpResponse.json(webSettings)), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production", "preview-team"])), - ), - http.put( - "http://localhost/api/workspace/projects/web/profile-bindings/deploy", - async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ + it.each([ + { status: "missing" as const, placeholder: "No build task configured" }, + { status: "invalid" as const, placeholder: "Unable to read build configuration" }, + ])( + "keeps project settings usable when the build task is $status", + async ({ status, placeholder }) => { + server.use( + http.get("http://localhost/api/workspace/projects/web", () => + HttpResponse.json({ ...webSettings, project: { ...webSettings.project, - deploy: { - ...webSettings.project.deploy, - selectedProfile: "preview-team", - profile: { - name: "preview-team", - source: "workspace-project-environment", - }, - }, + build: { source: "Taskfile.yml#tasks.build", status }, }, - }); - }, - ), - ); - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Deploy"); - const profile = await within(inspector).findByLabelText("Project profile"); - await chooseSelect(user, profile, "preview-team"); - await waitFor(() => expect(requestBody).toEqual({ profile: "preview-team" })); - await waitFor(() => - expect( - (within(inspector).getByLabelText("Project profile") as HTMLButtonElement).disabled, - ).toBe(false), - ); - - await user.click(within(inspector).getByRole("tab", { name: "Overview" })); - expect(screen.queryByRole("alertdialog")).toBeNull(); - expect( - within(inspector).getByRole("tab", { name: "Overview" }).getAttribute("aria-selected"), - ).toBe("true"); - - await user.click(within(inspector).getByRole("button", { name: "api services/api" })); - await waitFor(() => + }), + ), + ); + renderOverview(); + const inspector = await openProjectSettings(); + const command = (await within(inspector).findByLabelText( + "Build command", + )) as HTMLInputElement; + expect(command.value).toBe(""); + expect(command.placeholder).toBe(placeholder); + expect(command.readOnly).toBe(true); + expect(within(inspector).getByText(/one build reads Taskfile.yml#tasks.build/)).toBeDefined(); expect( - within(inspector) - .getByRole("button", { name: "api services/api" }) - .getAttribute("aria-current"), - ).toBe("page"), - ); - expect(screen.queryByRole("alertdialog")).toBeNull(); - }); + (within(inspector).getByLabelText("Development command") as HTMLInputElement).disabled, + ).toBe(false); + }, + ); - it("auto-saves a project Profile before changing environment", async () => { - const requestedEnvironments: string[] = []; - let requestBody: unknown; - server.use( - http.get("http://localhost/api/workspace/projects/web", ({ request }) => { - const selectedEnvironment = new URL(request.url).searchParams.get("env") ?? ""; - requestedEnvironments.push(selectedEnvironment); - return HttpResponse.json({ ...webSettings, environment: selectedEnvironment }); - }), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production", "preview-team"])), - ), - http.put( - "http://localhost/api/workspace/projects/web/profile-bindings/deploy", - async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json({ - ...webSettings, - project: { - ...webSettings.project, - deploy: { - ...webSettings.project.deploy, - selectedProfile: "preview-team", - profile: { - name: "preview-team", - source: "workspace-project-environment", - }, - }, - }, - }); - }, - ), - ); + it("keeps project environment configuration separate from remote secret operations", async () => { const user = userEvent.setup(); renderOverview(); - const inspector = await openProjectSettingsTab(user, "Deploy"); - const profile = await within(inspector).findByLabelText("Project profile"); - await chooseSelect(user, profile, "preview-team"); - await waitFor(() => expect(requestBody).toEqual({ profile: "preview-team" })); - await waitFor(() => - expect( - (within(inspector).getByLabelText("Project profile") as HTMLButtonElement).disabled, - ).toBe(false), - ); - - await selectEnvironment(user, "Development", "Preview"); - await waitFor(() => - expect(screen.getByTestId("environment-search").textContent).toBe("?env=preview"), - ); - await waitFor(() => expect(requestedEnvironments).toContain("preview")); - expect(screen.queryByRole("alertdialog")).toBeNull(); - }); - - it("scopes project reads and empty Profile writes to workspace and preview environment", async () => { - let requestBody: unknown; - let readEnvironment = ""; - let writeEnvironment = ""; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/projects/web", ({ request }) => { - readEnvironment = new URL(request.url).searchParams.get("env") ?? ""; - return HttpResponse.json({ ...webSettings, environment: "preview" }); - }), - http.get("http://localhost/api/configure/deploy/vercel", () => - HttpResponse.json(sectionResponse("deploy", "vercel", ["production"])), - ), - http.put( - "http://localhost/api/workspaces/demo-entry/projects/web/profile-bindings/deploy", - async ({ request }) => { - requestBody = await request.json(); - writeEnvironment = new URL(request.url).searchParams.get("env") ?? ""; - return HttpResponse.json({ - ...webSettings, - environment: "preview", - project: { - ...webSettings.project, - deploy: { - ...webSettings.project.deploy, - selectedProfile: "", - profile: { name: "production", source: "default" }, - }, - }, - }); - }, - ), - ); - const user = userEvent.setup(); - renderOverview(overview, "demo-entry", false, false, "preview"); - const inspector = await openProjectSettingsTab(user, "Deploy"); - const profile = await within(inspector).findByLabelText("Project profile"); - await chooseSelect(user, profile, "Resolve automatically (workspace / default)"); + const inspector = await openProjectSettingsTab(user, "Environment"); + const backendConfig = within(inspector).getByTestId("environment-settings-grid"); - await waitFor(() => expect(requestBody).toEqual({ profile: "" })); - expect(readEnvironment).toBe("preview"); - expect(writeEnvironment).toBe("preview"); + expect(within(backendConfig).queryByLabelText("Project profile")).toBeNull(); + expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); }); }); diff --git a/apps/dashboard/src/pages/Overview.tsx b/apps/dashboard/src/pages/Overview.tsx index 92405aa4..c3e6b433 100644 --- a/apps/dashboard/src/pages/Overview.tsx +++ b/apps/dashboard/src/pages/Overview.tsx @@ -18,7 +18,7 @@ export const Overview: React.FC<{ const projects = data.projects ?? []; return ( -
+
{readOnly ? ( diff --git a/apps/dashboard/src/pages/SectionDetail.tsx b/apps/dashboard/src/pages/SectionDetail.tsx deleted file mode 100644 index f4961917..00000000 --- a/apps/dashboard/src/pages/SectionDetail.tsx +++ /dev/null @@ -1,335 +0,0 @@ -// SectionDetail renders one catalog-backed Settings section. Backend identity, -// defaults and fields come from GET /api/catalog, so adding an adapter does not -// require another switch in the Dashboard. - -import { Check, Eye, EyeOff, Plus, Star, Trash2 } from "lucide-react"; -import type React from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; -import useSWR from "swr"; -import { humanizeBackendName, useBackendCatalog } from "@/api/catalog"; -import { getSection, removeProfile, sectionKey, setDefault } from "@/api/configure"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty"; -import { Spinner } from "@/components/ui/spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - emptyProfile, - ProfileEditorDialog, - type ProfileEditorTarget, - ProfileSummary, -} from "@/features/profile-editor/ProfileEditorDialog"; -import { useToast } from "@/hooks/useToast"; -import type { BackendSpec, SectionKey } from "@/types/api"; - -function backendTitle(t: ReturnType["t"], backend: BackendSpec): string { - return t(`sections.${backend.domain}.${backend.name}.title`, { - defaultValue: humanizeBackendName(backend.name), - }); -} - -function backendDescription( - t: ReturnType["t"], - backend: BackendSpec, -): string { - return t(`sections.${backend.domain}.${backend.name}.description`, { - defaultValue: backend.id, - }); -} - -export const SectionDetail: React.FC = () => { - const params = useParams<{ domain: string; backend: string }>(); - return ; -}; - -export const SectionDetailContent: React.FC<{ - domain: string; - backendName: string; - embedded?: boolean; -}> = ({ domain, backendName, embedded = false }) => { - const catalog = useBackendCatalog(); - const pair = `${domain}/${backendName}` as SectionKey; - const backend = catalog.byID.get(pair); - const toast = useToast(); - const { t } = useTranslation(); - const [reveal, setReveal] = useState(false); - const [editorTarget, setEditorTarget] = useState(null); - const [profileToRemove, setProfileToRemove] = useState(null); - const [removing, setRemoving] = useState(false); - - const swrKey = backend ? sectionKey(backend.domain, backend.name, reveal) : null; - const { data, error, isLoading, mutate } = useSWR(swrKey, () => { - if (!backend) return Promise.reject(new Error("unknown section")); - return getSection(backend.domain, backend.name, reveal); - }); - - if (catalog.error) { - return ( - - {t("settings.loadFailedTitle")} - {catalog.error.message} - - ); - } - if (catalog.isLoading) { - return ( -
- {t("detail.loading")} -
- ); - } - if (!backend || !backend.profile.configurable) { - return ( - - {t("detail.unknownSectionTitle")} - - {t("detail.unknownSectionBody", { - domain, - backend: backendName, - })} - - - ); - } - const selectedBackend = backend; - - const refresh = () => mutate(); - - async function onUse(name: string) { - try { - await setDefault(selectedBackend.domain, selectedBackend.name, name); - toast.success(t("toast.setDefault", { name })); - void refresh(); - } catch (err) { - const e = err as { code?: string; message: string }; - toast.error(e.message, { description: e.code }); - } - } - - async function onRemove(name: string) { - setRemoving(true); - try { - await removeProfile(selectedBackend.domain, selectedBackend.name, name); - toast.success(t("toast.removed", { name })); - setProfileToRemove(null); - void refresh(); - } catch (err) { - const e = err as { code?: string; message: string }; - toast.error(e.message, { description: e.code }); - } finally { - setRemoving(false); - } - } - - const profiles = data?.section.profiles ?? {}; - const defaultName = data?.section.default ?? ""; - const profileNames = Object.keys(profiles).sort(); - const title = backendTitle(t, backend); - const description = backendDescription(t, backend); - - return ( -
-
-
-
-

- {title} -

- - {backend.id} - -
-

{description}

-
-
- - {editorTarget === null ? ( - - ) : null} -
-
- - {error ? ( - - {t("detail.loadFailedTitle")} - {error.message} - - ) : null} - - { - if (!open) setEditorTarget(null); - }} - onSaved={() => { - void refresh(); - }} - /> - - { - if (!open && !removing) setProfileToRemove(null); - }} - > - - - {t("detail.remove")} - - {profileToRemove ? t("detail.confirmRemove", { name: profileToRemove }) : ""} - - - - {t("form.cancel")} - { - event.preventDefault(); - if (profileToRemove) void onRemove(profileToRemove); - }} - > - {t("detail.remove")} - - - - - -
- {isLoading ? ( -
- {t("detail.loading")} -
- ) : null} - {!isLoading && profileNames.length === 0 ? ( - - - {t("detail.empty")} - - - ) : null} - {profileNames.length > 0 ? ( - - -
- - - - {t("detail.tableProfile")} - {t("detail.tableSummary")} - - {t("detail.tableActions")} - - - - - {profileNames.map((name) => { - const profile = profiles[name]; - return ( - - -
- {name} - {name === defaultName ? ( - - {t("detail.default")} - - ) : null} -
-
- - - - -
- {name !== defaultName ? ( - - ) : null} - - -
-
-
- ); - })} -
-
-
-
-
- ) : null} -
-
- ); -}; diff --git a/apps/dashboard/src/pages/SectionsHome.tsx b/apps/dashboard/src/pages/SectionsHome.tsx deleted file mode 100644 index ba0ce43a..00000000 --- a/apps/dashboard/src/pages/SectionsHome.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// SectionsHome is the machine-level Settings surface. It lists configurable -// profile backends by domain; profile data is fetched only after drilling in. - -import { ChevronRight, ServerCog } from "lucide-react"; -import type React from "react"; -import { useTranslation } from "react-i18next"; -import { BACKEND_DOMAINS, humanizeBackendName, useBackendCatalog } from "@/api/catalog"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { cn } from "@/lib/utils"; - -export const SectionsHome: React.FC<{ - embedded?: boolean; - onSelect?: (domain: string, backend: string) => void; -}> = ({ embedded = false, onSelect }) => { - const { t } = useTranslation(); - const catalog = useBackendCatalog(); - - if (catalog.error) { - return ( - - {t("settings.loadFailedTitle")} - {catalog.error.message} - - ); - } - if (catalog.isLoading) { - return ( -
- {t("detail.loading")} - -
- - - -
-
- ); - } - - return ( -
- {embedded ? null : ( -
-

{t("settings.title")}

-

- {t("settings.description")} -

-
- )} - - {BACKEND_DOMAINS.map((domain) => { - const backends = (catalog.byDomain.get(domain) ?? []).filter( - (backend) => backend.profile.configurable, - ); - if (backends.length === 0) return null; - const groupLabel = t(`sections.groupLabel.${domain}`, { defaultValue: domain }); - return ( -
-
-

- {groupLabel} -

- {domain} -
-
- {backends.map((backend) => { - const title = t(`sections.${backend.domain}.${backend.name}.title`, { - defaultValue: humanizeBackendName(backend.name), - }); - const content = ( - <> - - - - - - {title} - - {backend.name} - - - - {t(`sections.${backend.domain}.${backend.name}.description`, { - defaultValue: backend.id, - })} - - - - {t("settings.manageBackend")} - - - - ); - const itemClass = - "group flex min-h-[72px] w-full items-center justify-start gap-4 rounded-none px-4 py-3 text-left font-normal whitespace-normal transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"; - return onSelect ? ( - - ) : ( - - {content} - - ); - })} -
-
- ); - })} -
- ); -}; diff --git a/apps/dashboard/src/pages/WorkspaceHome.test.tsx b/apps/dashboard/src/pages/WorkspaceHome.test.tsx index 7c919cd1..09ec922c 100644 --- a/apps/dashboard/src/pages/WorkspaceHome.test.tsx +++ b/apps/dashboard/src/pages/WorkspaceHome.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { MemoryRouter } from "react-router-dom"; @@ -73,15 +74,15 @@ function renderHome(path = "/") { ); } -describe("WorkspaceHome", () => { - beforeAll(async () => { - server.listen({ onUnhandledRequest: "error" }); - await i18n.changeLanguage("en-US"); - }); +beforeAll(async () => { + server.listen({ onUnhandledRequest: "error" }); + await i18n.changeLanguage("en-US"); +}); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +describe("WorkspaceHome", () => { it("shows an explicit loading state while the registry is being read", () => { let releaseRequest = () => {}; const pending = new Promise((resolve) => { @@ -172,3 +173,80 @@ describe("WorkspaceHome", () => { expect(screen.getByText(/Run one create to create a Workspace/)).toBeDefined(); }); }); + +describe("Workspace discovery and recovery", () => { + function serveRegistry() { + server.use( + http.get("http://localhost/api/workspaces", () => + HttpResponse.json({ schema: "one-cli/workspaces/v1", workspaces }), + ), + ); + } + + 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 "); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(screen.getByRole("link", { name: /Alpha/ }).getAttribute("href")).toBe( + "/workspace/alpha-entry?env=preview", + ); + await user.click(screen.getByRole("button", { name: /Needs attention/ })); + expect(screen.getByText("No matching workspaces")).toBeDefined(); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(document.activeElement).toBe(search); + expect(screen.getAllByRole("article")).toHaveLength(5); + await user.click(screen.getByRole("button", { name: /Needs attention/ })); + expect(screen.getAllByRole("article")).toHaveLength(4); + expect(screen.queryByRole("link", { name: /Alpha/ })).toBeNull(); + }); + + it("keeps existing workspaces during a failed refresh and supports retry", async () => { + serveRegistry(); + const user = userEvent.setup(); + renderHome(); + await screen.findByRole("link", { name: /Alpha/ }); + server.use( + http.get("http://localhost/api/workspaces", () => + HttpResponse.json({ error: { message: "Registry offline" } }, { status: 500 }), + ), + ); + await user.click(screen.getByRole("button", { name: "Refresh" })); + const alert = await screen.findByRole("alert"); + expect(screen.getAllByRole("article")).toHaveLength(5); + serveRegistry(); + await user.click(within(alert).getByRole("button", { name: "Retry" })); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); + expect(screen.getAllByRole("article")).toHaveLength(5); + }); + + it("keeps a failed removal open and removes only the selected workspace on retry", async () => { + serveRegistry(); + const user = userEvent.setup(); + renderHome(); + await screen.findByRole("link", { name: /Alpha/ }); + server.use( + http.delete("http://localhost/api/workspaces/alpha-entry", () => + HttpResponse.json({ error: { message: "Registry is busy" } }, { status: 500 }), + ), + ); + await user.click(screen.getByRole("button", { name: "Remove Alpha" })); + const dialog = await screen.findByRole("alertdialog"); + await user.click(within(dialog).getByRole("button", { name: "Remove Alpha" })); + expect((await within(dialog).findByRole("alert")).textContent).toContain("Registry is busy"); + expect(screen.getAllByRole("article", { hidden: true })).toHaveLength(5); + server.use( + http.delete( + "http://localhost/api/workspaces/alpha-entry", + () => new HttpResponse(null, { status: 204 }), + ), + ); + await user.click(within(dialog).getByRole("button", { name: "Remove Alpha" })); + await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); + expect(screen.queryByRole("link", { name: /Alpha/ })).toBeNull(); + expect(screen.getAllByRole("article")).toHaveLength(4); + }); +}); diff --git a/apps/dashboard/src/pages/WorkspaceHome.tsx b/apps/dashboard/src/pages/WorkspaceHome.tsx index 1be542c9..2074da86 100644 --- a/apps/dashboard/src/pages/WorkspaceHome.tsx +++ b/apps/dashboard/src/pages/WorkspaceHome.tsx @@ -1,6 +1,19 @@ -import { AlertTriangle, FolderGit2, FolderPlus, Trash2 } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-layout"; +import { + AlertTriangle, + ArrowUpRight, + CheckCircle2, + ChevronDown, + FolderGit2, + FolderPlus, + RefreshCw, + Search, + Terminal, + Trash2, + X, +} from "lucide-react"; import type React from "react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { forgetWorkspace, getWorkspaces, workspacesKey } from "@/api/workspaces"; @@ -15,6 +28,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Empty, @@ -23,8 +37,15 @@ import { EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; import { useToast } from "@/hooks/useToast"; import type { WorkspaceRegistryEntry } from "@/types/api"; @@ -32,64 +53,82 @@ import type { WorkspaceRegistryEntry } from "@/types/api"; function formatLastSeen(value: string, locale: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat(locale, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date); } -const WorkspaceCard: React.FC<{ - workspace: WorkspaceRegistryEntry; - onForget(): void; -}> = ({ workspace, onForget }) => { +const WorkspaceCard: React.FC<{ workspace: WorkspaceRegistryEntry; onForget(): void }> = ({ + workspace, + onForget, +}) => { const { t, i18n } = useTranslation(); - const locale = i18n.resolvedLanguage ?? i18n.language; const countUnavailable = (workspace.status === "missing" || workspace.status === "invalid") && workspace.projectCount === 0; - + const ready = workspace.status === "ready"; return ( -
+
-
- - +
+ + -
-

{workspace.name}

-
+

+ {workspace.name} +

- -
-
-

- {t("workspaces.home.projects")} -

-

+

+ {workspace.root} +

+
+
+ {countUnavailable ? "-" : workspace.projectCount} -

+
+ {t("workspaces.home.projects")}
+ + {ready ? : } + {t(`workspaces.status.${workspace.status}`)} +
- -
-
); }; @@ -98,133 +137,261 @@ export const WorkspaceHome: React.FC = () => { const { t } = useTranslation(); const toast = useToast(); const registry = useSWR(workspacesKey, getWorkspaces); + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState<"all" | "attention">("all"); + const searchRef = useRef(null); const [workspaceToForget, setWorkspaceToForget] = useState(null); const [forgetting, setForgetting] = useState(false); - + const [forgetError, setForgetError] = useState(""); + const workspaces = registry.data?.workspaces ?? []; + const attention = workspaces.filter((workspace) => workspace.status !== "ready").length; + const filtered = workspaces.filter( + (workspace) => + (filter === "all" || workspace.status !== "ready") && + `${workspace.name} ${workspace.root} ${workspace.id ?? ""}` + .toLocaleLowerCase() + .includes(query.trim().toLocaleLowerCase()), + ); + function clearFilters() { + setQuery(""); + setFilter("all"); + searchRef.current?.focus(); + } async function confirmForget() { if (!workspaceToForget || forgetting) return; setForgetting(true); + setForgetError(""); try { await forgetWorkspace(workspaceToForget.entryId); + await registry.mutate( + (current) => + current + ? { + ...current, + currentEntryId: + current.currentEntryId === workspaceToForget.entryId + ? undefined + : current.currentEntryId, + workspaces: current.workspaces.filter( + (entry) => entry.entryId !== workspaceToForget.entryId, + ), + } + : current, + { revalidate: false }, + ); toast.success(t("workspaces.forget.done", { name: workspaceToForget.name })); setWorkspaceToForget(null); - await registry.mutate(); } catch (error) { - toast.error(t("workspaces.forget.failed"), { - description: (error as { message?: string }).message, - }); + setForgetError((error as { message?: string }).message || t("workspaces.forget.failed")); } finally { setForgetting(false); } } - - if (registry.isLoading) { - return ( -
- {t("workspaces.home.loading")} - - - -
- ); - } - if (registry.error) { - const message = (registry.error as { message?: string }).message; - return ( - - -
- {t("workspaces.home.loadFailedTitle")} - -

{message ?? t("workspaces.home.loadFailedDescription")}

+ return ( + +
+ void registry.mutate()} + disabled={registry.isValidating} + aria-busy={registry.isValidating} > - {t("workspaces.home.retry")} + {registry.isValidating ? : } + {t("workspaces.home.refresh")} - -
- - ); - } - - const workspaces = registry.data?.workspaces ?? []; - - return ( -
-
-

- {t("workspaces.home.title")} -

-
- - {workspaces.length === 0 ? ( - - - - - - -

{t("workspaces.home.emptyTitle")}

-
- - {t("workspaces.home.emptyDescription")} - -
-
- ) : ( -
-
- {workspaces.map((workspace) => ( - setWorkspaceToForget(workspace)} - /> + } + /> + {registry.error ? ( + + + {t("workspaces.home.loadFailedTitle")} + +

+ {(registry.error as { message?: string }).message ?? + t("workspaces.home.loadFailedDescription")} +

+ +
+
+ ) : null} + {registry.isLoading && !registry.data ? ( +
+ {t("workspaces.home.loading")} + {[0, 1, 2].map((key) => ( + ))}
-
- )} - - !open && !forgetting && setWorkspaceToForget(null)} - > - - - - {workspaceToForget - ? t("workspaces.forget.action", { name: workspaceToForget.name }) - : ""} - - - {workspaceToForget - ? t("workspaces.forget.confirm", { name: workspaceToForget.name }) - : ""} - - - - {t("form.cancel")} - { - event.preventDefault(); - void confirmForget(); - }} - > - {forgetting ? : } - {workspaceToForget - ? t("workspaces.forget.action", { name: workspaceToForget.name }) - : ""} - - - - -
+ ) : registry.data ? ( + <> +
+ + +

+ {t("workspaces.home.registrationHint")} +

+
+ {workspaces.length === 0 ? ( + + + + + + +

{t("workspaces.home.emptyTitle")}

+
+ {t("workspaces.home.emptyDescription")} +
+
+ ) : ( +
+
+
+ + +
+ + + + + setQuery(event.target.value)} + placeholder={t("workspaces.home.search")} + aria-label={t("workspaces.home.search")} + /> + {query ? ( + + { + setQuery(""); + searchRef.current?.focus(); + }} + > + + + + ) : null} + +
+

+ {t("workspaces.home.resultCount", { count: filtered.length })} +

+ {filtered.length > 0 ? ( +
+ {filtered.map((workspace) => ( + { + setForgetError(""); + setWorkspaceToForget(workspace); + }} + /> + ))} +
+ ) : ( + + + + + + {t("workspaces.home.noResults")} + + {t("workspaces.home.noResultsDescription")} + + + + + )} +
+ )} + + ) : null} + !open && !forgetting && setWorkspaceToForget(null)} + > + + + + {workspaceToForget + ? t("workspaces.forget.action", { name: workspaceToForget.name }) + : ""} + + + {workspaceToForget + ? t("workspaces.forget.confirm", { name: workspaceToForget.name }) + : ""} + + + {forgetError ? ( +

+ {forgetError} +

+ ) : null} + + {t("form.cancel")} + { + event.preventDefault(); + void confirmForget(); + }} + > + {forgetting ? : } + {workspaceToForget + ? t("workspaces.forget.action", { name: workspaceToForget.name }) + : ""} + + +
+
+
+ ); }; diff --git a/apps/dashboard/src/providers/I18nProvider.tsx b/apps/dashboard/src/providers/I18nProvider.tsx index a5b5a24c..bd013456 100644 --- a/apps/dashboard/src/providers/I18nProvider.tsx +++ b/apps/dashboard/src/providers/I18nProvider.tsx @@ -9,7 +9,7 @@ // // On first mount we ALSO read /api/preferences and, if no local // override was stored, adopt whatever the CLI thinks. This makes the -// "first time the dashboard opens after a `one configure locale`" +// "first time the dashboard opens after a `one locale`" // case work without the user noticing the round-trip. import { type ReactNode, useEffect, useRef } from "react"; diff --git a/apps/dashboard/src/router/SettingsDialog.tsx b/apps/dashboard/src/router/SettingsDialog.tsx index e0c1ebcc..86f07290 100644 --- a/apps/dashboard/src/router/SettingsDialog.tsx +++ b/apps/dashboard/src/router/SettingsDialog.tsx @@ -1,6 +1,3 @@ -import { ArrowLeft, Settings2 } from "lucide-react"; -import type React from "react"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -11,73 +8,23 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { SectionDetailContent } from "@/pages/SectionDetail"; -import { SectionsHome } from "@/pages/SectionsHome"; - -interface SelectedBackend { - domain: string; - backend: string; -} - -export const SettingsDialog: React.FC = () => { +import { AccountSettings } from "@/features/infisical-session/AccountSettings"; +export function SettingsDialog() { const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [selected, setSelected] = useState(null); - return ( - { - setOpen(next); - if (!next) setSelected(null); - }} - > + - - -
- {selected ? ( - - ) : ( - - - - )} -
- {t("settings.title")} - {t("settings.description")} -
-
+ + + {t("sidebar.settings")} + {t("session.description")} -
- {selected ? ( - - ) : ( - setSelected({ domain, backend })} - /> - )} -
+
); -}; +} diff --git a/apps/dashboard/src/router/routes.test.tsx b/apps/dashboard/src/router/routes.test.tsx index ddc178d2..31332992 100644 --- a/apps/dashboard/src/router/routes.test.tsx +++ b/apps/dashboard/src/router/routes.test.tsx @@ -7,6 +7,8 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { SWRConfig } from "swr"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import i18n from "@/lib/i18n"; +import { App } from "@/App"; +import { TopBar } from "@/components/TopBar"; import { AppRoutes } from "@/router/routes"; import type { BackendSpec, @@ -15,7 +17,11 @@ import type { WorkspacesResponse, } from "@/types/api"; -const server = setupServer(); +const server = setupServer( + http.get("http://localhost/api/session", () => + HttpResponse.json({ session: { loggedIn: false, expired: false } }), + ), +); const alpha: WorkspaceRegistryEntry = { entryId: "alpha-entry", @@ -52,7 +58,6 @@ const infisicalBackend: BackendSpec = { domain: "env", name: "infisical", capabilities: ["env-load"], - profile: { configurable: true, fields: [] }, project: { configurable: false }, }; @@ -96,6 +101,7 @@ function renderDashboard(path = "/") { return render( new Map(), dedupingInterval: 10_000 }}> +
@@ -110,21 +116,18 @@ function registerCatalogHandler() { http.get("http://localhost/api/catalog", () => HttpResponse.json({ schema: "one-cli/catalog/v1", backends: [] }), ), - http.get( - "http://localhost/api/workspaces/:entryId/profile-bindings/env", - ({ params, request }) => { - const entry = params.entryId === beta.entryId ? beta : alpha; - return HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: entry.root, - environment: new URL(request.url).searchParams.get("env") ?? "dev", - domain: "env", - backend: "dotenv", - configurable: false, - selectedProfile: "", - }); - }, - ), + http.get("http://localhost/api/workspaces/:entryId/environment", ({ params, request }) => { + const entry = params.entryId === beta.entryId ? beta : alpha; + return HttpResponse.json({ + schema: "one-cli/workspace-environment/v1", + revision: "sha256:fixture", + root: entry.root, + environment: new URL(request.url).searchParams.get("env") ?? "dev", + domain: "env", + backend: "dotenv", + configurable: false, + }); + }), http.get( "http://localhost/api/workspaces/:entryId/projects/:projectName", ({ params, request }) => { @@ -175,24 +178,6 @@ function registerSettingsHandlers() { backends: [infisicalBackend], }), ), - http.get("http://localhost/api/configure", () => - HttpResponse.json({ - schema: "one-cli/serve-configure-config/v1", - config_path: "/machine/config.json", - credentials_path: "/machine/credentials.json", - reveal: false, - config: { version: 1 }, - }), - ), - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json({ - schema: "one-cli/serve-configure-section/v1", - domain: "env", - backend: "infisical", - reveal: false, - section: { profiles: {} }, - }), - ), ); } @@ -265,7 +250,7 @@ describe("multi-workspace routing", () => { const content = within(screen.getByTestId("route-content")); expect(await content.findByRole("heading", { name: "Workspaces" })).toBeDefined(); expect(content.getByRole("heading", { name: "No Workspaces yet" })).toBeDefined(); - expect(content.queryByRole("heading", { name: "Settings" })).toBeNull(); + expect(content.queryByRole("heading", { name: "Infisical" })).toBeNull(); expect(screen.getByTestId("location").textContent).toBe("/"); expect(screen.getByTestId("location-search").textContent).toBe("?env=preview"); }); @@ -311,7 +296,7 @@ describe("multi-workspace routing", () => { const confirmation = await screen.findByRole("alertdialog"); expect( within(confirmation).getByText( - 'Remove Workspace "Broken"? This only removes the local registry entry; no project files or Profiles will be deleted.', + 'Remove Workspace "Broken"? This only removes the local registry entry; no project files or remote variables will be deleted.', ), ).toBeDefined(); await user.click(within(confirmation).getByRole("button", { name: "Remove Broken" })); @@ -349,12 +334,12 @@ describe("multi-workspace routing", () => { ).toBe(true); }); - it("opens machine profile management at the Settings route", async () => { + it("opens single-account settings", async () => { registerSettingsHandlers(); renderDashboard("/settings"); - expect(await screen.findByRole("heading", { name: "Settings" })).toBeDefined(); + expect(await screen.findByRole("heading", { name: "Infisical" })).toBeDefined(); expect(screen.getByTestId("location").textContent).toBe("/settings"); expect(screen.queryByText("env/infisical")).toBeNull(); }); @@ -366,7 +351,7 @@ describe("multi-workspace routing", () => { await waitFor(() => expect(screen.getByTestId("location").textContent).toBe("/settings")); expect(screen.getByTestId("location-search").textContent).toBe("?env=prod"); - expect(await screen.findByRole("heading", { name: "Settings" })).toBeDefined(); + expect(await screen.findByRole("heading", { name: "Infisical" })).toBeDefined(); }); it("redirects legacy section URLs to the corresponding Settings backend", async () => { @@ -374,48 +359,37 @@ describe("multi-workspace routing", () => { renderDashboard("/section/env/infisical?env=preview"); - await waitFor(() => - expect(screen.getByTestId("location").textContent).toBe("/settings/env/infisical"), - ); + await waitFor(() => expect(screen.getByTestId("location").textContent).toBe("/settings")); expect(screen.getByTestId("location-search").textContent).toBe("?env=preview"); expect(await screen.findByRole("heading", { name: "Infisical" })).toBeDefined(); }); - it("confirms a destructive Profile removal before calling the API", async () => { - let deletedProfile = ""; - registerSettingsHandlers(); + it("exposes shared credentials and account settings through the actual application navigation", async () => { + await i18n.changeLanguage("en-US"); server.use( - http.get("http://localhost/api/configure/env/infisical", () => - HttpResponse.json({ - schema: "one-cli/serve-configure-section/v1", - domain: "env", - backend: "infisical", - reveal: false, - section: { default: "work", profiles: { work: {} } }, - }), + http.get("http://localhost/api/session", () => + HttpResponse.json({ session: { loggedIn: false, expired: false } }), + ), + http.get("http://localhost/api/global-env/location", () => + HttpResponse.json({ location: null }), + ), + http.get("http://localhost/api/workspaces", () => + HttpResponse.json({ schema: "one-cli/workspaces/v1", workspaces: [] }), ), - http.delete("http://localhost/api/configure/env/infisical/:name", ({ params }) => { - deletedProfile = String(params.name); - return HttpResponse.json({ - schema: "one-cli/serve-configure-remove/v1", - status: "removed", - name: deletedProfile, - }); - }), ); const user = userEvent.setup(); - - renderDashboard("/settings/env/infisical"); - expect(await screen.findByText("work")).toBeDefined(); - - await user.click(screen.getByRole("button", { name: "Delete" })); - const confirmation = await screen.findByRole("alertdialog"); - expect( - within(confirmation).getByText('Delete profile "work"? This cannot be undone.'), - ).toBeDefined(); - expect(deletedProfile).toBe(""); - - await user.click(within(confirmation).getByRole("button", { name: "Delete" })); - await waitFor(() => expect(deletedProfile).toBe("work")); + render( + new Map(), shouldRetryOnError: false }}> + + + + , + ); + const navigation = await screen.findAllByRole("link", { name: "Shared credentials" }); + await user.click(navigation[0]); + await screen.findByRole("heading", { name: "Shared credentials" }); + await user.click(screen.getByRole("link", { name: "Sign in with browser" })); + await screen.findByRole("heading", { name: "Infisical" }); + expect(screen.getByRole("button", { name: "Sign in with browser" })).toBeTruthy(); }); }); diff --git a/apps/dashboard/src/router/routes.tsx b/apps/dashboard/src/router/routes.tsx index 7d3d1671..1fca4017 100644 --- a/apps/dashboard/src/router/routes.tsx +++ b/apps/dashboard/src/router/routes.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, FolderX, RefreshCw } from "lucide-react"; +import { AlertTriangle, ArrowLeft, FolderX, RefreshCw } from "lucide-react"; import type React from "react"; import { useTranslation } from "react-i18next"; import { Navigate, type RouteObject, useLocation, useParams, useRoutes } from "react-router-dom"; @@ -7,7 +7,7 @@ import { getOverview, overviewKeyFor } from "@/api/workspace"; import { getWorkspaces, workspacesKey } from "@/api/workspaces"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty"; +import { StatePanel } from "@/components/ui/page-layout"; import { Skeleton } from "@/components/ui/skeleton"; import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; import { @@ -15,19 +15,30 @@ import { preserveEnvironment, } from "@/features/environment-context/environment"; import { Overview } from "@/pages/Overview"; -import { SectionDetail } from "@/pages/SectionDetail"; -import { SectionsHome } from "@/pages/SectionsHome"; +import { AccountSettings } from "@/features/infisical-session/AccountSettings"; +import { GlobalVariables } from "@/features/global-variables/GlobalVariables"; + import { WorkspaceHome } from "@/pages/WorkspaceHome"; import type { WorkspaceRegistryEntry } from "@/types/api"; const NotFoundRoute: React.FC = () => { const { t } = useTranslation(); return ( - - - {t("notFound.message")} - - + + + + + ); }; @@ -66,16 +77,44 @@ const WorkspaceRoute: React.FC = () => { shouldRetryOnError: false, }); - if (registry.isLoading && !registry.data) return ; - if (registry.error) return void registry.mutate()} />; - if (!workspace) return ; + if (registry.isLoading && !registry.data) + return ( + + + + ); + if (registry.error) + return ( + + void registry.mutate()} /> + + ); + if (!workspace) + return ( + + + + ); if (workspace.status !== "ready" && workspace.status !== "identity-conflict") { - return ; + return ( + + + + ); } if (overview.error) { - return void overview.mutate()} />; + return ( + + void overview.mutate()} /> + + ); } - if (overview.isLoading || !overview.data) return ; + if (overview.isLoading || !overview.data) + return ( + + + + ); return ( { ); }; +const WorkspaceStateLayout: React.FC = ({ children }) => ( +
+
{children}
+
+); + const WorkspaceLoading: React.FC = () => { const { t } = useTranslation(); return (
- - + +
); }; @@ -99,7 +144,7 @@ const WorkspaceLoading: React.FC = () => { const WorkspaceRegistryError: React.FC<{ onRetry(): void }> = ({ onRetry }) => { const { t } = useTranslation(); return ( - +
@@ -125,17 +170,17 @@ const WorkspaceRegistryError: React.FC<{ onRetry(): void }> = ({ onRetry }) => { const WorkspaceStatusPage: React.FC<{ workspace: WorkspaceRegistryEntry }> = ({ workspace }) => { const { t } = useTranslation(); return ( - +
-

+

{t("workspaces.workspaceLabel")}

{workspace.name}

-

+

{workspace.root}

@@ -145,6 +190,12 @@ const WorkspaceStatusPage: React.FC<{ workspace: WorkspaceRegistryEntry }> = ({ {t(`workspaces.state.${workspace.status}.description`)}

{t("workspaces.forget.pageHint")}

+

@@ -157,7 +208,7 @@ const WorkspaceLoadError: React.FC<{ }> = ({ workspace, onRetry }) => { const { t } = useTranslation(); return ( - +
@@ -180,7 +231,7 @@ const WorkspaceLoadError: React.FC<{ const UnknownWorkspace: React.FC = () => { const { t } = useTranslation(); return ( - +
@@ -200,8 +251,9 @@ const UnknownWorkspace: React.FC = () => { const routes: RouteObject[] = [ { path: "/", element: }, { path: "/workspace/:entryId", element: }, - { path: "/settings", element: }, - { path: "/settings/:domain/:backend", element: }, + { path: "/settings", element: }, + { path: "/global", element: }, + { path: "/settings/:domain/:backend", element: }, { path: "/profile", element: }, { path: "/section/:domain/:backend", element: }, { path: "*", element: }, diff --git a/apps/dashboard/src/styles/reset.css b/apps/dashboard/src/styles/reset.css index 89eb1246..f2837d5e 100644 --- a/apps/dashboard/src/styles/reset.css +++ b/apps/dashboard/src/styles/reset.css @@ -114,5 +114,16 @@ h6 { } ::selection { - background: rgb(234 88 12 / 0.22); + background: color-mix(in srgb, var(--primary) 22%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } diff --git a/apps/dashboard/src/styles/tailwind.css b/apps/dashboard/src/styles/tailwind.css index a476612c..60cbe935 100644 --- a/apps/dashboard/src/styles/tailwind.css +++ b/apps/dashboard/src/styles/tailwind.css @@ -4,11 +4,24 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme inline { + --breakpoint-ud-sm: 37.5rem; + --breakpoint-ud-md: 64rem; + --breakpoint-ud-lg: 90rem; + --breakpoint-ud-xl: 120rem; + --color-primary-action: var(--primary-action); + --color-primary-hover: var(--primary-hover); + --color-primary-text: var(--primary-text); + --color-surface-subtle: var(--surface-subtle); --font-sans: var(--font-family-sans); --font-mono: var(--font-family-mono); --font-heading: var(--font-family-heading); --text-xs: var(--font-size-xs); + --text-xs--line-height: 1.25rem; + --text-sm--line-height: 1.375rem; + --text-xl--line-height: 1.875rem; + --text-2xl--line-height: 2.25rem; + --text-3xl--line-height: 2.875rem; --text-sm: var(--font-size-sm); --text-base: var(--font-size-md); --text-lg: var(--font-size-lg); @@ -91,7 +104,7 @@ } body { - @apply min-h-dvh bg-background font-sans text-foreground antialiased; + @apply min-h-dvh bg-background font-sans text-sm text-foreground antialiased; font-variant-numeric: tabular-nums; } diff --git a/apps/dashboard/src/styles/tokens.css b/apps/dashboard/src/styles/tokens.css index 991c7fcb..dd933d64 100644 --- a/apps/dashboard/src/styles/tokens.css +++ b/apps/dashboard/src/styles/tokens.css @@ -84,13 +84,17 @@ --radius-scale-sm: 0.375rem; --radius-scale-md: 0.5rem; --radius-scale-lg: 0.625rem; - --radius-scale-xl: 0.75rem; + --radius-scale-xl: 0.625rem; --radius-scale-2xl: 1rem; --radius-scale-full: 9999px; --radius: var(--radius-scale-lg); - --shadow-scale-sm: 0 1px 2px rgb(28 39 58 / 0.06); - --shadow-scale-md: 0 12px 28px -20px rgb(28 39 58 / 0.32); + --shadow-scale-sm: + 0 1px 2px -2px rgb(31 35 39 / 0.02), 0 2px 4px rgb(31 35 39 / 0.02), + 0 2px 8px 2px rgb(31 35 39 / 0.02); + --shadow-scale-md: + 0 2px 4px -4px rgb(31 35 39 / 0.02), 0 4px 8px rgb(31 35 39 / 0.02), + 0 4px 16px 4px rgb(31 35 39 / 0.02); --shadow-scale-lg: 0 24px 56px -32px rgb(28 39 58 / 0.42); --shadow-scale-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 10px 10px -5px rgb(0 0 0 / 0.04); --shadow-scale-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); @@ -103,7 +107,7 @@ --font-family-mono: "Geist Mono", "IBM Plex Mono", "SF Mono", "Monaco", "Roboto Mono", monospace; - --font-size-xs: 0.8125rem; + --font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-md: 1rem; --font-size-lg: 1.125rem; @@ -124,31 +128,34 @@ --line-height-relaxed: 1.625; --line-height-loose: 2; - --background: rgb(246 247 249); - --foreground: rgb(20 27 38); + --background: color-mix(in srgb, var(--primary) 2%, #f8f9fa); + --foreground: #242321; --card: rgb(255 255 255); - --card-foreground: rgb(20 27 38); + --card-foreground: var(--foreground); --popover: rgb(255 255 255); - --popover-foreground: rgb(15 23 42); + --popover-foreground: var(--foreground); --primary: var(--color-primary-500); + --primary-action: var(--color-primary-600); + --primary-hover: var(--color-primary-700); + --primary-text: var(--color-primary-700); --primary-foreground: rgb(255 255 255); - --secondary: rgb(255 241 232); + --secondary: color-mix(in srgb, var(--primary) 9%, white); --secondary-foreground: rgb(154 52 18); - --muted: rgb(239 242 246); - --muted-foreground: rgb(79 91 109); - --accent: rgb(255 241 232); + --muted: color-mix(in srgb, var(--primary) 2%, #f0f1f2); + --muted-foreground: #696660; + --accent: var(--secondary); --accent-foreground: rgb(154 52 18); --destructive: rgb(196 61 77); --destructive-foreground: rgb(255 255 255); - --border: rgb(220 226 234); - --input: rgb(207 216 227); + --border: color-mix(in srgb, var(--primary) 3%, #e1e2e3); + --input: #948c83; --ring: var(--color-primary-500); --success-surface: var(--color-success-50); --success-border: var(--color-success-300); --success-foreground: var(--color-success-800); - --info-surface: var(--color-info-50); - --info-border: var(--color-info-300); - --info-foreground: var(--color-info-800); + --info-surface: var(--secondary); + --info-border: var(--color-primary-200); + --info-foreground: var(--primary-text); --warning-surface: var(--color-warning-50); --warning-border: var(--color-warning-300); --warning-foreground: var(--color-warning-800); @@ -156,44 +163,47 @@ --error-border: var(--color-error-300); --error-foreground: var(--color-error-800); - --surface-subtle: rgb(242 245 248); + --surface-subtle: var(--muted); --surface-raised: rgb(255 255 255); --text-muted: var(--muted-foreground); --hero-glow-primary: rgb(234 88 12 / 0.18); --hero-glow-info: rgb(19 194 194 / 0.14); - --sidebar: rgb(242 245 248); - --sidebar-foreground: rgb(17 24 39); - --sidebar-muted: rgb(107 114 128); - --sidebar-border: rgb(220 226 234); - --sidebar-active: rgb(255 241 232); + --sidebar: color-mix(in srgb, var(--primary) 2%, #fafafa); + --sidebar-foreground: var(--foreground); + --sidebar-muted: var(--muted-foreground); + --sidebar-border: var(--border); + --sidebar-active: var(--accent); } :root[data-theme="dark"] { - --background: rgb(9 14 22); - --foreground: rgb(238 244 252); - --card: rgb(16 24 36 / 0.96); - --card-foreground: rgb(238 244 252); - --popover: rgb(18 27 40); - --popover-foreground: rgb(238 244 252); - --primary: rgb(251 146 60); + --background: color-mix(in srgb, var(--primary) 2%, #151515); + --foreground: #f5f3f0; + --card: color-mix(in srgb, var(--primary) 2%, #1f1f1f); + --card-foreground: var(--foreground); + --popover: color-mix(in srgb, var(--primary) 3%, #262626); + --popover-foreground: var(--foreground); + --primary: var(--color-primary-400); + --primary-action: var(--primary); + --primary-hover: var(--color-primary-300); + --primary-text: var(--color-primary-300); --primary-foreground: rgb(26 10 4); - --secondary: rgb(67 20 7); + --secondary: color-mix(in srgb, var(--primary) 12%, #202020); --secondary-foreground: rgb(254 215 170); - --muted: rgb(25 35 50); - --muted-foreground: rgb(157 171 191); - --accent: rgb(42 13 4); + --muted: color-mix(in srgb, var(--primary) 3%, #292929); + --muted-foreground: #b3aca4; + --accent: var(--secondary); --accent-foreground: rgb(254 215 170); --destructive: rgb(248 113 113); --destructive-foreground: rgb(12 20 34); - --border: rgb(42 55 74); - --input: rgb(56 70 91); + --border: color-mix(in srgb, var(--primary) 5%, #3a3a3a); + --input: #776e66; --ring: rgb(251 146 60); --success-surface: var(--color-success-900); --success-border: var(--color-success-400); --success-foreground: var(--color-success-100); - --info-surface: var(--color-info-900); - --info-border: var(--color-info-400); - --info-foreground: var(--color-info-100); + --info-surface: var(--secondary); + --info-border: var(--color-primary-700); + --info-foreground: var(--primary-text); --warning-surface: var(--color-warning-900); --warning-border: var(--color-warning-400); --warning-foreground: var(--color-warning-100); @@ -201,15 +211,15 @@ --error-border: var(--color-error-400); --error-foreground: var(--color-error-100); - --surface-subtle: rgb(13 21 32 / 0.82); - --surface-raised: rgb(18 27 40 / 0.96); + --surface-subtle: var(--muted); + --surface-raised: var(--popover); --text-muted: var(--muted-foreground); --hero-glow-primary: rgb(234 88 12 / 0.2); --hero-glow-info: rgb(19 194 194 / 0.14); - --sidebar: rgb(12 19 30); - --sidebar-foreground: rgb(245 248 252); - --sidebar-muted: rgb(145 160 181); - --sidebar-border: rgb(34 49 74); - --sidebar-active: rgb(42 32 29); + --sidebar: color-mix(in srgb, var(--primary) 2%, #1b1b1b); + --sidebar-foreground: var(--foreground); + --sidebar-muted: var(--muted-foreground); + --sidebar-border: var(--border); + --sidebar-active: var(--accent); } } diff --git a/apps/dashboard/src/types/api.ts b/apps/dashboard/src/types/api.ts index e7a6d382..197344a4 100644 --- a/apps/dashboard/src/types/api.ts +++ b/apps/dashboard/src/types/api.ts @@ -1,28 +1,16 @@ // types/api.ts mirrors the transport-neutral shapes exposed by the Go -// application layer. Backend identities and profile fields intentionally come +// application layer. Backend identities and project fields intentionally come // from GET /api/catalog instead of a second hard-coded frontend registry. -export type BackendDomain = "env" | "deploy" | "container"; +export type BackendDomain = "env"; export type SectionKey = `${BackendDomain}/${string}`; -export type ProfileValue = string | number | boolean | null | AnyProfile | ProfileValue[]; -export interface AnyProfile { - [key: string]: ProfileValue | undefined; -} - -export type BackendFieldType = "string" | "secret" | "boolean"; - -export interface BackendFieldSpec { - path: string; - input_name: string; - type: BackendFieldType; - label_key: string; - required?: boolean; - placeholder?: string; - default?: ProfileValue; +export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +export interface JsonObject { + [key: string]: JsonValue | undefined; } export interface BackendRequirement { - kind: "binary" | "capability" | "profile"; + kind: "binary" | "capability"; name: string; optional?: boolean; } @@ -34,10 +22,6 @@ export interface BackendSpec { capabilities: string[]; traits?: string[]; requirements?: BackendRequirement[]; - profile: { - configurable: boolean; - fields?: BackendFieldSpec[]; - }; project?: { configurable: boolean; fields?: ProjectFieldSpec[]; @@ -60,57 +44,6 @@ export interface CatalogResponse { backends: BackendSpec[]; } -// ──────────────────────────── per-section payload shape ───────────────── - -export interface Section { - default?: string; - profiles?: Record; -} - -export type Config = { version: number } & Partial>>; - -// ──────────────────────────── server response envelopes ───────────────── - -export interface ConfigResponse { - schema: "one-cli/serve-configure-config/v1"; - config_path: string; - credentials_path: string; - reveal: boolean; - config: Config; -} - -export interface SectionResponse { - schema: "one-cli/serve-configure-section/v1"; - domain: string; - backend: string; - reveal: boolean; - section: Section; -} - -export interface UpsertResponse { - schema: "one-cli/serve-configure-upsert/v1"; - status: "completed" | "updated"; - domain: string; - backend: string; - name: string; - default: boolean; -} - -export interface UseResponse { - schema: "one-cli/serve-configure-use/v1"; - domain: string; - backend: string; - name: string; -} - -export interface RemoveResponse { - schema: "one-cli/serve-configure-remove/v1"; - status: "removed"; - domain: string; - backend: string; - name: string; -} - // ──────────────────────────── error envelope ──────────────────────────── export interface RemediationStep { @@ -147,7 +80,7 @@ export interface HttpError { // Returned by singular or registry-scoped Workspace overview routes. // `present: false` is retained for the legacy launch-root route. -export type OverviewIssueDomain = "container" | "deploy" | "env"; +export type OverviewIssueDomain = "env"; export type OverviewIssueSeverity = "missing"; export type OverviewIssueReason = "backend" | "profile"; @@ -169,7 +102,6 @@ export interface OverviewProject { kind: OverviewProjectKind; templateId?: string; toolchain?: string; - compatibleDeployTargets?: string[]; domains?: Partial>; issues?: OverviewIssue[]; } @@ -220,50 +152,12 @@ export interface WorkspacesResponse { // ─────────────────────────── project configuration ───────────────────── -export interface ProfileBinding { - name: string; - source: "workspace-project" | "workspace" | "default" | string; -} - -export type ProjectProfileBinding = ProfileBinding; - -export interface WorkspaceProfileSettings { - schema: "one-cli/workspace-profile/v1"; - root: string; - environment?: string; - revision: string; - domain: "env"; - backend?: string; - configurable: boolean; - selectedProfile?: string; - profile?: ProfileBinding; -} - export interface ProjectEnvironmentSettings { backend?: string; path?: string; inherits: boolean; disabled: boolean; keys?: string[]; - selectedProfile?: string; - profile?: ProjectProfileBinding; -} - -export interface ProjectContainerSettings { - enabled: boolean; - backend?: string; - image?: string; - namespace?: string; - selectedProfile?: string; - profile?: ProjectProfileBinding; -} - -export interface ProjectDeploySettings { - backend?: string; - compatibleTargets?: string[]; - config?: Record; - selectedProfile?: string; - profile?: ProjectProfileBinding; } export interface ProjectSettings { @@ -275,11 +169,14 @@ export interface ProjectSettings { packageManager?: string; buildVersion?: string; devCommand?: string; + build?: { + command?: string; + source?: string; + status: "ready" | "missing" | "invalid"; + }; defaultEnvironment?: string; availableEnvironments?: string[]; environment: ProjectEnvironmentSettings; - container: ProjectContainerSettings; - deploy: ProjectDeploySettings; } export interface ProjectSettingsResponse { @@ -303,20 +200,11 @@ export interface ProjectEnvironmentPatch { disabled: boolean; } -export interface ProjectContainerPatch { - enabled: boolean; - backend: string; - image: string; - namespace: string; -} - -export interface ProjectDeployPatch { - backend: string; - config: Record; -} - export interface WorkspaceEnvironmentPatch { backend: string; + projectId?: string; + projectName?: string; + siteUrl?: string; } export interface WorkspaceManifestPatch { @@ -327,11 +215,10 @@ export interface ProjectManifestPatch { project: string; general?: ProjectGeneralPatch; environment?: ProjectEnvironmentPatch; - container?: ProjectContainerPatch; - deploy?: ProjectDeployPatch; } export interface ApplyManifestRequest { + workspace?: WorkspaceManifestPatch; revision: string; changes: ProjectManifestPatch[]; } @@ -379,3 +266,12 @@ export interface SecretMutationResponse { action?: "created" | "updated" | "unchanged"; status?: "deleted"; } + +export interface WorkspaceEnvironmentSettings { + schema: string; + revision: string; + backend: string; + projectId: string; + projectName: string; + siteUrl: string; +} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 66672dbd..61156051 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ codeSplitting: { groups: [ { + debugName: "vendor-groups", name(id) { if (!id.includes("node_modules")) return null; if ( diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts index 68b28fe5..d90914a8 100644 --- a/apps/dashboard/vitest.config.ts +++ b/apps/dashboard/vitest.config.ts @@ -1,5 +1,5 @@ import { defineConfig, mergeConfig } from "vitest/config"; -import viteConfig from "./vite.config"; +import viteConfig from "./vite.config.ts"; const nodeMajor = Number.parseInt(process.versions.node, 10); diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md index dbb30343..1cced8ec 100644 --- a/apps/docs/content/docs/en/add.md +++ b/apps/docs/content/docs/en/add.md @@ -15,7 +15,7 @@ There are two entry points: ## Usage ```bash -one add [template-id] --name [--deploy-provider ] [options] +one add [template-id] --name [options] ``` ## Arguments @@ -25,7 +25,6 @@ one add [template-id] --name [--deploy-provider ] [optio | `template-id` | Template ID, such as `nestjs-api`. Omit it for interactive selection | | `-n, --name` | Project name; required in non-interactive mode | | `-y, --yes` | Non-interactive mode | -| `--deploy-provider ` | Explicit deploy backend; must be in the template's compat list | | `-o, --output ` | `json` / `yaml` / `text` | The workspace root uses pnpm. Each project's toolchain comes from the template: Node templates use the workspace package manager, Go templates use the Go toolchain, and so on. @@ -104,7 +103,6 @@ one add nestjs-api --name user-api --yes -o json | jq - Registers the project in `one.manifest.json#projects[]` - Writes the project's local development command - Leaves continuous integration unconfigured -- Leaves deployment and image configuration absent until first deploy Non-blocking sync issues are reported in `warnings[]`; the project is still added. @@ -130,6 +128,6 @@ Not sure which one to use? Read the [template decision tree](/en/docs/templates/ - Check `one.manifest.json#projects[]` to confirm registration - Agent docs and local-development configuration are synced by `one add` -- Run `one dev ` next; choose deployment later with `one deploy ` +- Run `one dev ` for development and `one build ` to build - Optionally run `one ci enable ` to generate its GitHub Actions workflow - `one add` does not install dependencies: JS / TS workspaces install from the root with the package manager; Go projects run `go mod download` in the project directory, then `go mod tidy` only after changing imports or when module metadata needs repair diff --git a/apps/docs/content/docs/en/ai-native.md b/apps/docs/content/docs/en/ai-native.md index 04da7f52..29e04caf 100644 --- a/apps/docs/content/docs/en/ai-native.md +++ b/apps/docs/content/docs/en/ai-native.md @@ -107,8 +107,8 @@ One CLI can manage env, container, and deploy configuration, but agents should n Recommended boundary: -- `one.manifest.json` records reviewable Workspace/Project/Backend configuration; local Profile names never enter it. -- `one configure` manages machine Profiles, while `one serve` opens a local `127.0.0.1` UI for Profile values, environment-aware local bindings, and reviewed revision-checked Backend/Project configuration drafts. Source files and non-allowlisted Manifest fields stay view-only there. +- `one.manifest.json` records reviewable Workspace/Project/Backend configuration; secret values and session tokens never enter it. +- `one login` manages one browser session in the system keyring. `one serve` exposes account settings, global-variable metadata, and reviewed Manifest drafts. - `.env*`, private keys, and cloud tokens stay out of Git and out of reusable agent-facing docs. - Agents can read structured state, install missing dependencies, and scaffold projects, but publishing, deletion, and credential overwrites should go through team policy or human confirmation. diff --git a/apps/docs/content/docs/en/build.md b/apps/docs/content/docs/en/build.md new file mode 100644 index 00000000..d56bd184 --- /dev/null +++ b/apps/docs/content/docs/en/build.md @@ -0,0 +1,43 @@ +--- +title: one build +description: Build all projects or one selected project. +--- + +`one build` prepares tools and application dependencies, then runs project build tasks to completion. + +```bash +one build +one build web +one build apps/web +one build -p web --env prod +one build --dry-run -o json +``` + +Without a selector, builds all buildable manifest projects, even when invoked from a project directory. A project name or workspace-relative path selects only that project; its local dependencies are not automatically built. + +## Build commands + +The Dashboard project overview shows the resolved build command and its source. This field is read-only; edit the project's `package.json` or `Taskfile.yml`, then refresh the page to see the update. + +- Node: runs `build` from the current `package.json` using the workspace package manager (`pnpm`, `npm`, `yarn`, or `bun`). +- Go: runs `task build` from the project's `Taskfile.yml`. The Go API template writes `bin/server`; the Go library template compiles packages with `go build ./...`. Older libraries can add that task to their Taskfile. + +Go projects require a Taskfile. Full workspace builds skip projects without a build task and report `no-build-task`; explicitly selecting one fails with `RUNTIME_TASK_NOT_FOUND`. Invalid configuration and missing required files fail before preparation. A workspace with no build tasks also fails. Build scripts control artifact locations. + +## Ordering and execution + +Full builds run sequentially, with local Node dependencies before their consumers. Dependencies, devDependencies, and optionalDependencies are matched by package name, or by directory for `file:` / `link:` dependencies. Independent projects retain manifest traversal order. Duplicate package names and dependency cycles are reported before execution. Go resolves its package dependencies through the Go toolchain. + +The first failure stops the build. Remaining tasks are reported as `not_run`, and the failing child's exit code is preserved. Ctrl+C stops the active process tree. Each project's logs carry its name. + +## Tools, dependencies, and environments + +Uses the same automatic mise/builtin selection and dependency preparation as `one dev`, including libraries without dev commands. Node dependencies are installed once at the workspace root. Each build runs through the `one run` environment-loading and PATH rules, in its project directory. `--env` selects an environment; otherwise the manifest default applies. + +`--dry-run` reads configuration and reports ordered commands, directories, and skipped projects. It does not install tools or dependencies, load secrets, access the network, or write files. + +## Output + +`-o json` and `-o yaml` return `one-cli/build-plan/v1` for previews and `one-cli/build-result/v1` for execution results. Process logs go to stderr, leaving stdout parseable. Results include per-project status, command, duration, and exit code. Preparation failures include an error and leave build tasks `not_run`. + +Use [`one run`](/docs/run/) for custom commands. diff --git a/apps/docs/content/docs/en/cli-overview.md b/apps/docs/content/docs/en/cli-overview.md index 77657f1a..d9706d1f 100644 --- a/apps/docs/content/docs/en/cli-overview.md +++ b/apps/docs/content/docs/en/cli-overview.md @@ -1,210 +1,21 @@ --- title: CLI overview -description: One CLI top-level commands, common subcommands, output modes, and automation contracts. +description: Daily commands and advanced entry points. --- -One CLI is a single binary. It creates workspaces, adds projects, manages environment variables and endpoint profiles, runs local dev / container / deployment workflows, and exposes stable JSON output for agents and CI. - -**Who this page is for**: people who just installed One CLI and want to know which commands exist; people who cannot remember a flag. - -**After reading**: you will know each public command's one-line purpose, minimal example, common subcommands, and where to jump next for details. - -## Top-level commands - -| Command | Purpose | Minimal example | -|---|---|---| -| `one create` | Scaffold a new workspace | `one create my-app` | -| `one add` | Add a project interactively or from templates | `one add` | -| `one templates` | List available templates | `one templates` | -| `one env` | Manage dotenv / Infisical environment variables | `one env list` | -| `one container` | Inspect, build, and push Dockerfile-driven images | `one container info` | -| `one dev` | Start every project's local dev process in parallel | `one dev` | -| `one deploy` | Dispatch per-project deploys to kustomize / S3-compatible / Vercel / Cloudflare / EdgeOne | `one deploy --dry-run` | -| `one ci` | Inspect or manage optional continuous integration | `one ci` | -| `one run` | Run a command with project `.env` injected | `one run -- npm test` | -| `one configure` | Configure machine-level endpoint profiles | `one configure` | -| `one serve` | Launch the local Workspace, Project, and Profile Dashboard | `one serve` | - -## Create Workspaces - -```bash -one create [dir] [--name ] [--env-provider dotenv|infisical] [--yes] -``` - -`[dir]` is the target directory. The workspace name defaults to `basename(dir)`. Create produces an empty workspace with local dotenv and `one dev`; it does not configure CI or ask for projects or deployment. - -Read [Create](/en/docs/create/). - -## Add Projects - -```bash -one add # open the interactive picker -one templates # see available templates -one add --name [--yes] # add a specific stack -``` - -Bare `one add` asks which directory group to add to (application, service, or shared library), then the technology stack, then the project name. Documentation sites are applications. It does not configure CI or ask about deployment. Ordinary add leaves deployment unset until `one deploy `; `--deploy-provider` remains an advanced automation option. - -Read [Add](/en/docs/add/). - -## Templates - -```bash -one templates -one templates -o json -``` - -`one templates` lists bundled templates. Agents and CI should use `-o json` to read template IDs, categories, toolchains, and compatible backends. - -Read [Templates](/en/docs/templates-cmd/). - -## Environment Variables - -```bash -one env get [--env ] [-p ] -one env set [VALUE] [--env ] [-p ] -one env list [--env ] [-p ] -one env pull [--env ] [-p ] [--force] [--dry-run] -``` - -`one env` dispatches to the workspace's selected env backend. `dotenv` reads and writes local `.env` overlays; `infisical` supports remote get / set / list / pull. `--env` selects an environment such as dev, staging, or prod. `-p / --project` selects a project by manifest name or workspace-relative path. - -Read [Secrets](/en/docs/env-vars/). - -## Local Connections - -```bash -one configure -one configure add -one configure add --profile [backend flags...] [--use] -one configure list [pair] -one configure current [pair] -one configure show --profile [--reveal] -one configure use --profile -one configure remove --profile -one configure locale [auto|zh-CN|en-US] -one configure open -``` - -`configure` manages local connections and preferences. With no connections, bare `one configure` opens the setup wizard; otherwise it shows a concise overview. `show`, `use`, and `remove` allow terminal selection. Scripts keep explicit service IDs and `--profile` names for compatibility. Credentials stay in local files, never the workspace or Git. - -Supported `` values: - -| Domain | Backends | -|---|---| -| `env` | `infisical` | -| `container` | `docker` | -| `container` | `dockerhub`, `ghcr`, `acr` | -| `deploy` | `aliyun-oss`, `tencent-cos`, `aws-s3`, `minio`, `rustfs`, `r2` | -| `deploy` | `kustomize`, `vercel`, `cloudflare`, `edgeone` | - -Local `.env` files do not need a machine-level connection. -Local connections are stored in `~/.config/one/config.json` and `~/.config/one/credentials.json`. Environment-aware Workspace/Project selections contain names only and live in `~/.config/one/profile-bindings.json`. Sensitive fields are masked unless you explicitly run `show --reveal`; none of these files changes `one.manifest.json`. -When adding tokens, prefer `one configure open` so you do not hand tokens to an AI agent. - -## Interactive Mode At A Glance - -| Command | Interactive behavior | -|---|---| -| `one create` | Yes; no-arg mode asks for target directory and optional workspace name | -| `one add` | Yes; no-arg mode picks project kind, technology stack, and project name | -| `one configure` | Yes; bare `one configure` or `one configure add` opens the local-connection wizard | -| `one env set` | Yes; hidden value input, scope selection, and overwrite confirmation; scripts pass the value | -| `one container build` | Partial; TTY mode can choose a build version, CI uses `--build-version` | -| `one deploy` | First deployment asks for project, target category/service, and local connection; scripts pass `--provider` and `--profile` | -| `one dev` | Missing Node dependencies trigger an install confirmation; otherwise starts immediately | -| `one ci disable` | Asks before removing generated workflow files; refusal exits successfully | -| `one templates` / `one run` | No wizard; behavior is controlled by arguments | -| `one serve` | Not a terminal wizard; it opens a local Dashboard for Workspaces, Projects, and local connections | - -## Local Web UI - -```bash -one serve [--host 127.0.0.1] [--port 0] [--open=false] -``` - -Starts a loopback-only HTTP server for humans to edit `env / deploy / container` Profiles, select environment-aware local bindings, and review typed Workspace Backend or Project configuration drafts before publishing them with revision checks. Workspace source code and non-allowlisted Manifest fields remain read-only. This path handles API keys, kubeconfig paths, and registry tokens, so it is intentionally not an AI-agent credential-editing interface. - -Read [Serve](/en/docs/serve/). - -## Containers - -```bash -one container info -one container build [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -one container push [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -``` - -`one container` reads each project's Dockerfile and manifest container config. Bare `build` creates a local `:` image. Passing `--profile`, or resolving a machine-local registry binding/default, produces a registry-qualified tag and performs login. `push` requires a registry Profile and can retag the local image before pushing. - -## Local Development - -```bash -one dev [project] [--dry-run] -``` - -Reads project dev commands and starts every developable project in parallel. The positional project starts only one; `--project` remains for old scripts. Missing Node dependencies can be installed after confirmation. - -## Deployment - -```bash -one deploy [project] [--provider ] [--profile ] [--dry-run] -``` - -On first deployment, One CLI shows only compatible targets already implemented by this repository, then asks for a local connection. Choosing "configure later" exits successfully without changing the workspace. Later runs reuse the saved project deployment target. - -`--env ` overrides the deploy target for this run. `--dry-run` prints the docker / kubectl / S3 / platform CLI plan without touching remote systems. - -## Continuous Integration - -```bash -one ci -one ci enable [project] -one ci sync [project] -one ci disable [project] -``` - -CI is optional and is never added by `one create` or `one add`. The current -build generates GitHub Actions workflows. Omit `[project]` to operate on all -projects (`sync` refreshes only projects where CI is already enabled). - -Read [Continuous integration](/en/docs/ci/). - -## Run With Env - -```bash -one run [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...] -``` - -Runs the child process in the resolved project directory after injecting secrets. By default it uses the workspace manifest's env provider; pass `--env-provider` to force dotenv or Infisical. - -## Output Modes - -Every command supports the same output flags: - -| Trigger | Mode | -|---|---| -| `-o json` or `--output json` | Force pretty-printed JSON | -| `-o yaml` or `--output yaml` | Force YAML with the same schema as JSON | -| `-o text` or `--output text` | Force human output | -| Default + pipe / non-TTY | JSON | -| Default + TTY | Colored human output | - -Running `one templates` directly shows terminal-friendly output. -Agents and CI get JSON by default when reading through a pipe. -Scripts should still pass `-o json` explicitly so parsing does not depend on the execution environment. - -## Meta Commands - -```bash -one --version -one --help -one help --all -one --help -``` - -`one --help` shows the six everyday tasks. Use `one help --all` for the complete command catalogue and `one --help` for exact flags. - -## `one skills install` - -Install or refresh the bundled `one-cli` skill for selected coding agents. Use `--agent ` (repeatable) for explicit targets, or `--yes` for all detected agents. Installation is offline, independent of the workspace, and repeatable. See [Skills](./skills). +| Command | Purpose | +| --- | --- | +| `one create` | Create a workspace | +| `one add` | Add a project | +| `one dev` | Start development | +| `one build` | Build projects | +| `one env` | Manage project variables | +| `one login` / `one whoami` / `one logout` | Single browser session | +| `one env --global` | Discover global variable locations and environments | +| `one run` | Run a command with injected variables | +| `one serve` | Open the Dashboard | +| `one locale` | Local language preference | +| `one init mise` / `one init hooks` | Workspace tool configuration | +| `one ci` / `one templates` / `one skills` | Automation and resources | + +Discover the full catalogue with `one help --all`, then read command-specific `--help`. Agents discover metadata and execution options through the CLI, without copying commands from the Dashboard. See [login and shared credentials](/en/docs/login/). diff --git a/apps/docs/content/docs/en/configure.md b/apps/docs/content/docs/en/configure.md deleted file mode 100644 index 5d7a9e88..00000000 --- a/apps/docs/content/docs/en/configure.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: one configure -description: Manage local connections and preferences for deployment, environment variables, and image registries. ---- - -`one configure` manages **local connections and preferences**, not application code. Credentials stay on this machine and are never written to the workspace or Git. - -## Usage - -```bash -one configure -one configure add -one configure add --profile [backend flags...] [--use] -one configure list [pair] -one configure current [pair] -one configure show --profile [--reveal] -one configure use --profile -one configure remove --profile -one configure locale [auto|zh-CN|en-US] -one configure open -``` - -With no connections, bare `one configure` opens the setup wizard. With existing connections it shows a concise overview. `show`, `use`, and `remove` let terminal users select an existing connection; scripts keep explicit `` and `--profile` inputs. - -## Interactive Mode - -For local human setup, use the wizard: - -```bash -one configure -one configure add -``` - -The wizard first asks which service to connect, then asks for a connection name and the required service fields. Stable service IDs remain visible in automation commands. Secret fields use password-style input. - -Scripts and CI should not wait for the wizard; pass the service ID, connection name (`--profile`), and service flags explicitly. - -## Supported pairs - -| pair | purpose | -|---|---| -| `env/infisical` | Infisical site URL + Universal Auth client id / secret | -| `deploy/aliyun-oss` | Aliyun OSS object storage | -| `deploy/tencent-cos` | Tencent COS object storage | -| `deploy/aws-s3` | AWS S3 | -| `deploy/minio` | self-hosted MinIO | -| `deploy/rustfs` | self-hosted RustFS | -| `deploy/r2` | Cloudflare R2 | -| `deploy/kustomize` | Kubernetes kubeconfig + context | -| `deploy/vercel` | Vercel API token | -| `deploy/cloudflare` | Cloudflare API token | -| `deploy/edgeone` | Tencent EdgeOne Pages API token | -| `container/docker` | Generic Docker registry host, namespace, username, password | -| `container/dockerhub` | Docker Hub username, password/token, namespace | -| `container/ghcr` | GitHub Container Registry username, PAT, namespace | -| `container/acr` | Aliyun ACR region, username, password/token, namespace | - -`env/dotenv` does not need a profile; it is for local `.env` workflows. The S3-compatible deploy backends share one profile shape, but each provider has its own backend ID. - -## Examples - -```bash -one configure add env/infisical --profile work \ - --client-id "$INFISICAL_CLIENT_ID" \ - --client-secret "$INFISICAL_CLIENT_SECRET" \ - --use - -one configure add deploy/aws-s3 --profile web-prod \ - --region us-east-1 \ - --access-key-id "$AWS_ACCESS_KEY_ID" \ - --access-key-secret "$AWS_SECRET_ACCESS_KEY" \ - --use - -one configure add deploy/kustomize --profile prod-k8s \ - --kubeconfig ~/.kube/config \ - --kubeconfig-context prod \ - --use - -one configure add container/ghcr --profile ghcr \ - --namespace "$GITHUB_USER" \ - --username "$GITHUB_USER" \ - --password "$GHCR_PAT" \ - --use -``` - -## Resolution order - -When a command needs a profile, it resolves in this order: - -1. `--profile ` -2. Project + environment binding in `profile-bindings.json` -3. Workspace + environment binding in `profile-bindings.json` -4. legacy Project binding in `config.json#workspaces` -5. legacy Workspace binding in `config.json#workspaces` -6. `~/.config/one/config.json#domain/backend.default` - -The environment-aware bindings are keyed by canonical Workspace root, environment, and `(domain, backend)`. They store only a Profile name. The Dashboard UI offers `dev`, `preview`, and `prod` through `?env=`; the core/API also accepts safe custom IDs supplied by other workflows. Global Settings Profile CRUD is not environment-scoped. An empty environment keeps the legacy chain. - -`one.manifest.json` never stores a local Profile name. `one configure use ... --workspace` and `--project` remain compatible legacy bindings; use `one serve` when you need a distinct selection for each environment. - -The same profile name can exist under different backends, for example `prod` under both `deploy/aws-s3` and `deploy/kustomize`. - -## Storage - -```text -~/.config/one/ -├── config.json # non-secret Profile fields, defaults, legacy bindings -├── credentials.json # secrets: clientSecret, accessKeySecret, password -├── profile-bindings.json # v1: canonical root + environment -> Profile names -└── cache/ # short-lived token cache -``` - -All three JSON files are machine-local and written as `0600`; `profile-bindings.json` contains names only. None of them modifies or upgrades `one.manifest.json`. `show` masks secrets by default; only `show --reveal` prints cleartext. - -## Output schemas - -| command | schema | -|---|---| -| `add` | `one-cli/configure-add/v1` | -| `list ` | `one-cli/configure-list/v1` | -| `list` | `one-cli/configure-list-all/v1` | -| `current ` | `one-cli/configure-current/v1` | -| `current` | `one-cli/configure-current-all/v1` | -| `show` | `one-cli/configure-show/v1` | -| `use` | `one-cli/configure-use/v1` | -| `remove` | `one-cli/configure-remove/v1` | - -## Common errors - -| code | fix | -|---|---| -| `PROFILE_NONE_CONFIGURED` | run `one configure add --profile --use` | -| `PROFILE_NOT_FOUND` | run `one configure list ` and use an existing name | -| `PROFILE_BACKEND_INVALID` | use a profile whose backend matches the target project | -| `PROFILE_FILE_INVALID` | repair the file named in the error context (`config.json`, `credentials.json`, or `profile-bindings.json`) | -| `PROFILE_VERSION_UNSUPPORTED` | upgrade One CLI or recreate only the incompatible machine-local file | - -## Next - -- [one serve](/en/docs/serve/) — edit Profiles and choose environment-aware local bindings -- [one env](/en/docs/env-vars/) — use `env/infisical` -- [one deploy](/en/docs/deploy/) — use deploy profiles -- [one container](/en/docs/container/) — use container profiles diff --git a/apps/docs/content/docs/en/container.md b/apps/docs/content/docs/en/container.md deleted file mode 100644 index a1eaa5ef..00000000 --- a/apps/docs/content/docs/en/container.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: one container -description: Inspect, build, and push Dockerfile images for workspace projects. ---- - -`one container` operates on projects that declare `projects[].domains.container` in `one.manifest.json`. The current backend is Dockerfile-driven: templates provide Dockerfiles, and One CLI resolves projects, image names, registry tags, and Docker invocations. - -## Usage - -```bash -one container info -one container build [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -one container push [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -``` - -`[subproject]` and `-p / --project` select one project by manifest `name` or `relativeDir`. Omit them to target every container-enabled project. - -## Interactive Mode - -`one container info` and `one container push` do not open a wizard. In TTY mode, `one container build` can ask you to choose or type an image version when `--build-version` is omitted and One CLI cannot infer a stable version from the manifest, Git, or project metadata. - -Scripts, CI, and agents should pass `--build-version` and `--profile` explicitly, or use `--dry-run` first to inspect the Docker command. - -## info - -Read-only inspection: - -```bash -one container info -o json -``` - -Schema: `one-cli/container-info/v2`. - -## build - -```bash -one container build api -one container build -p services/api --build-version v0.1.0 -one container build --dry-run -``` - -By default, build creates a local tag: `:`. When `--profile` is passed or local Profile resolution selects a registry connection, the tag becomes `/[namespace/]:` and Docker login runs when credentials are present. - -Schema: `one-cli/container-build/v2`. - -## push - -```bash -one container push api --profile ghcr -one container push -p apps/web --build-version v0.1.0 --dry-run -``` - -`push` must resolve a container profile for the project's kind, such as `container/ghcr`, `container/dockerhub`, `container/acr`, or generic `container/docker`. If the registry-qualified tag is missing locally but the matching bare tag exists, CLI retags first and then pushes. - -Schema: `one-cli/container-push/v1`. - -## Profile resolution - -1. `--profile ` -2. Project + environment `container/` binding in `profile-bindings.json` -3. Workspace + environment `container/` binding in `profile-bindings.json` -4. legacy Project binding in `config.json#workspaces` -5. legacy Workspace binding in `config.json#workspaces` -6. `~/.config/one/config.json#container/.default` - -Container commands use the Manifest's default environment (or its first declared environment) as the binding key. The key is local and canonical-root-scoped; the Profile name never enters the Manifest. Use `one serve` to choose a different Profile per environment, or `--profile` for a one-shot build/push override. - -Configure once: - -```bash -one configure add container/ghcr --profile ghcr \ - --namespace "$GITHUB_USER" \ - --username "$GITHUB_USER" \ - --password "$GHCR_PAT" \ - --use -``` - -Supported kinds are `container/docker` for a generic registry, plus `container/dockerhub`, `container/ghcr`, and `container/acr`. - -## Manifest conditions - -`nestjs-api`, `go-api`, and `nextjs-app` enable `container/docker` by default. Libraries, mobile, and Electron templates do not. - -## Common errors - -| code | fix | -|---|---| -| `BACKEND_NOT_ENABLED` | choose a template with container support or add container config to the manifest | -| `REGISTRY_CREDENTIAL_MISSING` | run `one configure add container/ --profile --use` | -| `IMAGE_TAG_NOT_FOUND` | build first, or pass the same `--build-version` | -| `CONTAINER_BUILD_FAILED` | run the printed Docker command inside the project for full logs | - -## Next - -- [Build & push images](/en/tutorials/container-build-push/) -- [one configure](/en/docs/configure/) -- [one deploy](/en/docs/deploy/) diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md index 09613b6e..57412ab8 100644 --- a/apps/docs/content/docs/en/create.md +++ b/apps/docs/content/docs/en/create.md @@ -52,12 +52,6 @@ Continuous integration is not configured automatically. Creating a workspace does not write files under `.github/workflows/`. After adding a project, enable it explicitly with `one ci enable ` if needed. -**Deployment is delayed** - -Create does not write deployment or container configuration. Ordinary `one add` -also leaves it unset. The first `one deploy ` asks for a compatible -deployment target and local connection. - ## `--env-provider` Semantics `--env-provider ` explicitly selects the env backend: @@ -66,16 +60,13 @@ deployment target and local connection. one create my-app -y --env-provider infisical ``` -Configure a machine-level Infisical profile first: +Sign in to Infisical in your browser first: ```bash -one configure add env/infisical --profile work \ - --client-id $INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \ - --client-secret $INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET \ - --use +one login ``` -`one create --env-provider infisical` tries to auto-bind or create an Infisical project. If the profile, network, or permission is not ready, workspace creation still succeeds; the first `one env set/get/list/pull` retries lazy auto-bind. +`one create --env-provider infisical` tries to auto-bind or create an Infisical project. If login, network, or permission is not ready, workspace creation still succeeds; the first `one env set/get/list/pull` retries lazy auto-bind. ## Output diff --git a/apps/docs/content/docs/en/deploy.md b/apps/docs/content/docs/en/deploy.md deleted file mode 100644 index 945967ad..00000000 --- a/apps/docs/content/docs/en/deploy.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: one deploy -description: Dispatch projects to kustomize, S3-compatible storage, Vercel, Cloudflare, or EdgeOne deploy backends. ---- - -`one deploy` is the per-project deploy entry point. The first deployment chooses a compatible target and local connection; later runs reuse that project setup. - -## Usage - -```bash -one deploy [project] [--provider ] [--profile ] [--env ] [--dry-run] -``` - -## Options - -| option | purpose | -|---|---| -| positional `project` | deploy one project by manifest `name` or `relativeDir` | -| `-p`, `--project ` | legacy selector for scripts and CI | -| `--provider ` | explicit first-deploy target for automation | -| `--profile ` | one-shot local-connection override | -| `--env ` | override both deploy target environment and env-var environment | -| `--env-provider dotenv|infisical` | override the workspace env provider | -| `--build-version ` | CI/non-interactive image version, mainly for kustomize auto-build | -| `--dry-run` | print Docker, kubectl, object-storage, or platform CLI plans without remote side effects | - -## Interactive Mode - -For an unconfigured project, TTY mode asks in this order: - -1. Project (when omitted) -2. Deployment-target category -3. A compatible service already implemented by this build -4. Whether to configure a missing local connection now or later - -"Configure later" exits 0, does not modify the workspace, and prints the exact recovery command. Scripts use `one deploy --provider --profile `. - -## Backends - -| backend | project type | behavior | -|---|---|---| -| `kustomize` | APIs, SSR apps, container workloads | auto-builds and pushes the image, syncs the overlay, then runs `kubectl apply -k` | -| `aws-s3` / `aliyun-oss` / `tencent-cos` / `minio` / `rustfs` / `r2` | static sites | builds output, ensures the bucket, uploads through S3-compatible APIs | -| `vercel` | hosted frontend | deploys through Vercel | -| `cloudflare` | Cloudflare Workers | runs `wrangler deploy` | -| `edgeone` | EdgeOne Pages | runs `edgeone pages deploy` | - -## Environment mapping - -| backend | `prod` or empty | other env names | -|---|---|---| -| `kustomize` | `kustomize/overlays/prod` | `kustomize/overlays/` | -| `vercel` | production deploy | preview deploy | -| `cloudflare` | `wrangler deploy` | `wrangler deploy --env=` | -| `edgeone` | production deploy | preview deploy | -| S3-compatible | deploy target unchanged | deploy target unchanged; only build-time env changes | - -`--env` must exist in `one.manifest.json#environments.names`. - -## Profile resolution - -1. `--profile ` -2. Project + environment `deploy/` binding in `profile-bindings.json` -3. Workspace + environment `deploy/` binding in `profile-bindings.json` -4. legacy Project binding in `config.json#workspaces` -5. legacy Workspace binding in `config.json#workspaces` -6. `~/.config/one/config.json#deploy/.default` - -Environment-aware keys use the canonical Workspace root. For deploy resolution, the environment is the Project's configured deploy environment, falling back to `prod`; use `--profile` for a one-shot override. Select per-environment Workspace/Project names in `one serve`. The Dashboard environment comes from `?env=` and selecting it never changes the deploy environment or Manifest. - -Manifest files never store local Profile names. `one configure use --profile --workspace` and `--project ` continue to create compatible, environment-agnostic legacy bindings. - -## Examples - -```bash -one deploy --dry-run -one deploy web --env staging --dry-run -one deploy api --provider kustomize --profile prod-k8s --build-version v0.1.0 -``` - -## Output schemas - -| backend | schema | -|---|---| -| `kustomize` | `one-cli/deploy-apply/v1` | -| S3-compatible | `one-cli/deploy-apply/v1` | -| `vercel` | `one-cli/deploy-apply-vercel/v1` | -| `cloudflare` | `one-cli/deploy-apply-cloudflare/v1` | -| `edgeone` | `one-cli/deploy-apply-edgeone/v1` | - -## Common errors - -| code | fix | -|---|---| -| `BACKEND_NOT_ENABLED` | select a project and explicit target in non-interactive calls | -| `PROFILE_NOT_FOUND` | run `one configure list deploy/` | -| `PROFILE_NONE_CONFIGURED` | run `one configure add deploy/ --use` | -| `ENV_UNKNOWN_ENVIRONMENT` | add the env to `manifest.environments.names` or use an existing name | -| `REGISTRY_CREDENTIAL_MISSING` | configure `container/docker` before kustomize auto-build | - -## Next - -- [First deploy](/en/tutorials/deploy/) -- [Multi-backend deploy](/en/tutorials/deploy-multi-backend/) -- [one configure](/en/docs/configure/) -- [one container](/en/docs/container/) diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index 7a4a50ce..7dfeb1d5 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -20,9 +20,14 @@ one dev [project] [--dry-run] | `--dry-run` | print the supervisor command without starting processes | | `-o`, `--output ` | `json` / `yaml` / `text` | -## Interactive Mode +## Dependency preparation -If a selected Node project has no installed dependencies, a terminal asks whether to run the detected package manager's install command. Confirming installs and continues; declining exits successfully. Non-interactive calls return `DEPENDENCIES_NOT_INSTALLED` with the exact install command. +`one dev` prepares the selected projects before starting any development commands. Interactive and non-interactive calls use the same preparation flow. + +- Node dependencies are prepared at the workspace root. With pnpm 10.14 or later, One asks pnpm to verify the installed workspace state and reuses a matching installation, including one created by a manual `pnpm install`. When installation is needed, it runs `pnpm install --no-frozen-lockfile` so new projects and dependency changes can update the lockfile. Older pnpm versions use One's installation cache. Other package managers keep their existing lockfile policy. +- `one build` keeps the strict installation policy: an existing pnpm dependency lockfile uses `--frozen-lockfile`. If it is stale, install dependencies and review the lockfile changes before building. +- Go preparation downloads the fixed module build list or resolves workspace dependencies, maintaining checksums as needed. It does not run `go mod tidy` or `go work sync` automatically. +- A failed preparation stops startup. Package-manager diagnostics are streamed once; canceling stops the preparation process. `one run` does not install dependencies. ## Runner @@ -38,7 +43,8 @@ one dev apps/web --dry-run | code | fix | |---|---| -| `DEPENDENCIES_NOT_INSTALLED` | run the install command from remediation, then retry | +| `RUN_COMMAND_NOT_FOUND` | check the native tools or mise configuration | +| `ONE_CLI_ERROR` | fix the reported dependency error, then retry | | `SUBPROJECT_NOT_FOUND` | use a project `name` or `relativeDir` | ## Next diff --git a/apps/docs/content/docs/en/env-vars.md b/apps/docs/content/docs/en/env-vars.md index f6aca086..22247a0d 100644 --- a/apps/docs/content/docs/en/env-vars.md +++ b/apps/docs/content/docs/en/env-vars.md @@ -26,7 +26,7 @@ For the full workflow and mental model, read [Environment variables guide](/en/t ```bash one env set [VALUE] [--env ] [-p ] [--yes] -one env get [--env ] [-p ] +one env get [--env ] [-p ] --reveal one env list [--env ] [-p ] one env pull [--env ] [-p ] [--force] [--dry-run] ``` @@ -43,9 +43,9 @@ one env pull --env staging # pull staging vars for all projects The global output flag is `-o / --output`, with `json`, `yaml`, or `text`. -> There is no `one env init` subcommand today. Infisical project binding is attempted by `one create --env-provider infisical`. If profile, network, or permissions were not ready during create, the first `set/get/list/pull` retries lazy auto-bind. +> There is no `one env init` subcommand today. Infisical project binding is attempted by `one create --env-provider infisical`. If login, network, or permissions were not ready during create, the first `set/get/list/pull` retries lazy auto-bind. -Machine-level Infisical credentials are configured with [`one configure add env/infisical`](/en/docs/cli-overview/#machine-profiles). They do not go into the manifest. +Machine-level Infisical credentials are configured with [`one login`](/en/docs/login/). They do not go into the manifest. ## Interactive Mode @@ -108,8 +108,8 @@ Output schema: `one-cli/env-set/v1` Read one key: ```bash -one env get DATABASE_URL --env dev -p api -DB_URL=$(one env get DATABASE_URL --env dev -p api -o json | jq -r .value) +one env get DATABASE_URL --env dev -p api --reveal +DB_URL=$(one env get DATABASE_URL --env dev -p api -o json | jq -r .value) --reveal ``` Output schema: `one-cli/env-get/v1` @@ -188,8 +188,8 @@ Workspace env backend lives in `one.manifest.json#domains.env`; environments liv "domains": { "env": { "kind": "infisical", - "profile": "work", "config": { + "siteUrl": "https://app.infisical.com", "projectId": "...", "projectName": "my-workspace", "rootPath": "/" @@ -218,18 +218,18 @@ Project path overrides live in `projects[].domains.env`: } ``` -Values and local Profile names never go into the Manifest. The Manifest records the Backend, folder path, and key names; machine Profile definitions and environment-aware bindings stay under `~/.config/one/`. +Values never enter the Manifest. It records project identity, instance URL, folder paths, and key names. Authentication uses the single browser session in the system keyring. ## Credential Safety -`one configure add env/infisical` writes `~/.config/one/config.json` and `~/.config/one/credentials.json` with mode `0600`. Do not put client id or client secret in the repo; inject them through your CI secret store. +Use `one login`; the token lives only in the system keyring, outside project and ordinary configuration files. ## Common Errors | Code | Recovery | |---|---| -| `INFISICAL_NOT_CONFIGURED` | Confirm the workspace uses `--env-provider infisical` and has a default `env/infisical` profile | -| `INFISICAL_AUTH_MISSING` | Re-run `one configure add env/infisical --profile work ... --use` | +| `INFISICAL_NOT_CONFIGURED` | Confirm the workspace uses `--env-provider infisical` and you have signed in with `one login` | +| `INFISICAL_AUTH_MISSING` | Re-run `one login` | | `INFISICAL_AUTH_FAILED` | Regenerate the client secret in Infisical | | `INFISICAL_PROJECT_NAME_TAKEN` | Change `domains.env.config.projectName` and rerun an env command to trigger lazy bind | | `INFISICAL_PROJECT_CREATE_FORBIDDEN` | Grant admin role to the machine identity, or manually create the project and fill `domains.env.config.projectId` | @@ -245,3 +245,8 @@ Full table: [Error codes](/en/docs/error-codes/). - [Environment variables guide](/en/tutorials/env-vars/) — mental model and complete workflow - [`one create`](/en/docs/create/) — use `--env-provider infisical` during workspace creation + + +## Shared credentials + +Shared credentials are independent of workspaces. Select storage with `one env bind --global`, browse metadata with `one env list --global --env dev --path /`, and inject an explicit scope with `one run --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/) for commands and security boundaries. diff --git a/apps/docs/content/docs/en/error-codes.md b/apps/docs/content/docs/en/error-codes.md index 70c77971..8a35bdce 100644 --- a/apps/docs/content/docs/en/error-codes.md +++ b/apps/docs/content/docs/en/error-codes.md @@ -122,10 +122,6 @@ Template registry download, parsing, and lookup. Registry is empty. This is usually a registry packaging issue. -### `REGISTRY_CREDENTIAL_MISSING` - -Container push needs registry credentials. Use local build or configure `container/docker`. - ### `REGISTRY_FETCH_FAILED` Registry download failed. Check network and registry URL from `context`. @@ -160,9 +156,9 @@ Failures after manifest write, usually from per-domain backend sync during `crea A backend sync failed or rolled back after manifest write. Re-run the command after fixing the surfaced cause. -## Plugin / Profile / Deploy +## Backends and workspace configuration -Backend selection, profile resolution, deployment, and generated delivery artifacts. +Backend selection and generated workspace configuration. ### `CI_DISABLE_CONFIRMATION_REQUIRED` @@ -183,61 +179,17 @@ The selected project has no generated CI workflow. Run the command in CI backend failed while rendering workflow files. -### `IMAGE_REF_INCOMPLETE` - -Deploy / CI needs a complete image reference but registry/name/tag is missing. - -### `IMAGE_TAG_NOT_FOUND` - -Push target tag does not exist locally. Build first with `one container build `. - -### `IMAGE_TAG_REQUIRED` - -Container build could not infer a version tag. Pass `--build-version`, set `projects[].buildVersion`, or create a Git tag. - -### `K8S_PACKAGE_UNSUPPORTED` - -Selected Kubernetes packaging form is not bundled in this build. - -### `K8S_PLATFORM_UNDETECTED` - -Kubernetes node architecture could not be detected. Check kubeconfig/context and `kubectl get nodes -o wide`. - ### `LOCAL_ORCH_PORT_CONFLICT` Two projects requested the same dev port and the runner could not auto-allocate another. -### `PROFILE_ALREADY_EXISTS` - -Profile name already exists. Re-run `one configure add ... ` to update or choose another name. - -### `PROFILE_BACKEND_INVALID` - -Profile backend is not recognized or does not belong to the declared domain. - -### `PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED` - -Profile uses a credential source this build cannot read. Use `file` source. - -### `PROFILE_FILE_INVALID` - -One of `~/.config/one/config.json`, `credentials.json`, or `profile-bindings.json` is invalid JSON. Repair the exact path in `error.context`; deleting `profile-bindings.json` removes local selections, not Profile credentials or repository files. - -### `PROFILE_IN_USE` - -The Profile is still selected by an environment-aware Workspace or Project binding. Choose **Automatic** for every referencing binding in the Dashboard, then delete the Profile. +### `PREFERENCES_INVALID` -### `PROFILE_NONE_CONFIGURED` +The requested local preference value is not supported. -No Profile resolved from `--profile`, environment-aware Project/Workspace bindings, legacy bindings, or machine default. Run `one configure add / --profile work`. +### `PREFERENCES_FILE_INVALID` -### `PROFILE_NOT_FOUND` - -Requested profile does not exist. Run `one configure list ` or add the profile. - -### `PROFILE_VERSION_UNSUPPORTED` - -One machine-local Profile file schema does not match this binary. Upgrade CLI or recreate only the incompatible file; this never requires a Manifest upgrade. +The local preferences file could not be read or parsed. ### `RELEASE_FLOW_MISMATCH` @@ -311,11 +263,11 @@ Infisical returned an API error. Check status and context. ### `INFISICAL_AUTH_FAILED` -Universal Auth login failed. Rotate or verify credentials. +The browser session was rejected or expired. Run `one logout`, then `one login`. ### `INFISICAL_AUTH_MISSING` -No default Infisical credentials. Configure `env/infisical`. +There is no active browser session. Run `one login`. ### `INFISICAL_FOLDER_NOT_FOUND` @@ -366,18 +318,6 @@ Domain command was invoked where that domain is not configured. Add a template o Active backend does not implement this verb. Switch to a compatible backend. -### `CLOUDFLARE_CLI_MISSING` - -`wrangler` is missing. Install it locally or globally. - -### `CLOUDFLARE_DEPLOY_FAILED` - -`wrangler` failed. Check upstream logs, token, and account id. - -### `CLOUDFLARE_PROFILE_INVALID` - -Cloudflare profile is missing API token or required account data. - ### `DOMAIN_INVALID` Domain name is not recognized. @@ -394,18 +334,6 @@ Domain is recognized but no backend implementation is registered. Required domain section is missing from the manifest. -### `EDGEONE_CLI_MISSING` - -EdgeOne CLI is missing. Install it with npm or pnpm. - -### `EDGEONE_DEPLOY_FAILED` - -EdgeOne CLI failed. Check token and project configuration. - -### `EDGEONE_PROFILE_INVALID` - -EdgeOne profile is missing API token or required fields. - ### `PATCH_CONFLICT` Two backend patches conflict on the same target. @@ -432,20 +360,8 @@ Requested serve port is busy. Choose another or use `--port 0`. ### `SERVE_REPOSITORY_READ_ONLY` -The Dashboard rejected a repository or `one.manifest.json` mutation with HTTP 409. Make that configuration change through source control/code review; only machine Profiles and environment-aware Profile bindings are writable in `one serve`. +The requested repository mutation is not allowlisted. Use the reviewed Manifest draft for supported fields; edit source files through your normal development workflow. ### `SUBPROJECT_NOT_FOUND` `-p / --project` references a project not in `manifest.projects`. - -### `VERCEL_CLI_MISSING` - -Vercel CLI is missing. Install `vercel`. - -### `VERCEL_DEPLOY_FAILED` - -Vercel CLI failed. Check upstream logs, token, and project link. - -### `VERCEL_PROFILE_INVALID` - -Vercel profile is missing API token or team/project data. diff --git a/apps/docs/content/docs/en/installation.md b/apps/docs/content/docs/en/installation.md index 0e82b456..0cc74107 100644 --- a/apps/docs/content/docs/en/installation.md +++ b/apps/docs/content/docs/en/installation.md @@ -109,38 +109,9 @@ Download, migration, or verification failures return `MISE_INSTALL_FAILED`. Chec Use `one mise --version`, `one mise doctor`, or `one mise trust ` to work with the same selected runtime. Review configuration before trusting it; `MISE_PARANOID=1` requires explicit trust. Arguments, IO, and exit codes are forwarded, without One project secrets; use `one run` when those secrets are needed. -## Configure Provider Credentials +## Infisical login -Provider credentials are configured once with `one configure add / --profile ` and can be reused across workspaces. Current configurable pairs are: - -| pair | use when | -|---|---| -| `env/infisical` | Infisical machine identity | -| `deploy/aliyun-oss` | Aliyun OSS, S3 protocol object storage | -| `deploy/tencent-cos` | Tencent COS, S3 protocol object storage | -| `deploy/aws-s3` | AWS S3 | -| `deploy/minio` | self-hosted MinIO | -| `deploy/rustfs` | self-hosted RustFS | -| `deploy/r2` | Cloudflare R2 | -| `deploy/kustomize` | Kubernetes kubeconfig + context | -| `deploy/vercel` | Vercel API token | -| `deploy/cloudflare` | Cloudflare API token | -| `deploy/edgeone` | Tencent EdgeOne Pages API token | -| `container/docker` | Generic Docker registry login | -| `container/dockerhub` | Docker Hub login | -| `container/ghcr` | GitHub Container Registry login | -| `container/acr` | Aliyun ACR login | - -`env/dotenv` does not need remote credentials; it reads and writes local project `.env` files. The S3-compatible deploy backends share the same profile shape, but their backend IDs stay explicit (`deploy/aws-s3`, `deploy/aliyun-oss`, `deploy/r2`, etc.). - -Common examples: - -```bash -one configure add env/infisical --profile work # Infisical credentials -one configure add deploy/aws-s3 --profile web-prod # AWS S3 endpoint + AK/SK -one configure add deploy/kustomize --profile prod-k8s # kubeconfig context -one configure add container/ghcr --profile ghcr # GHCR username + PAT -``` +Run `one login` to sign in with a browser. The session is saved in your system keyring. See [login and shared credentials](/en/docs/login/). ## Environment Variables @@ -177,7 +148,7 @@ macOS / Linux: rm ~/.local/bin/one ``` -To remove local profile credentials and cache, delete `~/.config/one`. +Run `one logout` to remove the active session from the system keyring. ## Local Repo Build For Contributors diff --git a/apps/docs/content/docs/en/login.md b/apps/docs/content/docs/en/login.md new file mode 100644 index 00000000..53dfdc36 --- /dev/null +++ b/apps/docs/content/docs/en/login.md @@ -0,0 +1,56 @@ +--- +title: Login and local settings +description: Browser login, system keyring storage, and shared Infisical credentials. +--- + +## Browser login + +```bash +one login +one whoami +one logout +one login --site-url https://secrets.example.com +``` + +One keeps one active Infisical account. Complete login in the browser; the session token is stored in the system keyring. Client ID, Client Secret, and Profiles are no longer used. Log out before changing accounts or instances. An unavailable keyring causes an error with no plaintext fallback. Expired sessions require explicit login; reading variables never launches a browser automatically. Old credential files are neither read nor automatically deleted. + +## Shared credentials + +In the Dashboard, open Shared credentials and select **Initialize default location** to create or reuse the `shared-credentials` project with environment `dev` and root folder `/`. You can also create another project or choose an existing Secret Manager project. Existing saved locations are preserved. + +The CLI continues to use `--global` for shared credentials. To select a location manually: + +```bash +one env bind --global +one env bind --global --project-id PROJECT_ID --env dev +one env --global +one env list --global --env dev --path / +one env list --global --env dev --path /docker +one run --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script +``` + +Listings contain immediate folders, names, and descriptions, without values. Execution requires an explicit environment and path. It does not recurse, import other folders, or expand secret references. Use `--keys` to narrow injection further. Global mode works outside a workspace and preserves the current directory. Project commands, `one dev`, and `one build` do not automatically receive shared credentials. + +Read plaintext explicitly with `one env get KEY --global --env dev --path /docker --reveal`. Write with the interactive password prompt or `one env set KEY --global --env dev --path /docker --stdin`. Overwrites require `--yes`. Delete with `one env unset KEY --global --env dev --path /docker`. + +## Dashboard + +Run `one serve`. Settings manages browser login, pending callbacks, cancellation, logout, and language. Shared credentials manages the storage project, browsing environment, folders, and variables. Values are fetched only on reveal or copy and cleared when the account, environment, folder, or page changes. Remote edits take effect immediately. Workspace project bindings are reviewed as Manifest drafts and saved atomically with other draft changes. + +## Security boundaries + +Injection and output masking reduce accidental exposure; they do not isolate an Agent running arbitrary programs as the same OS user. Such an Agent can still retrieve or exfiltrate credentials. Descriptions are untrusted data. Limit Infisical and cloud permissions, environments, paths, and credential lifetime. Output masking is best effort for exact known values and cannot cover transformed output or files written by child processes. External tools such as Docker may persist credentials themselves. + +## Preferences and workspace tools + +```bash +one locale en-US +one locale zh-CN +one locale auto +one init mise --dry-run +one init mise +one init hooks --dry-run +one init hooks +``` + +`one init mise` generates tool configuration while preserving user configuration. `one init hooks` configures hk checks and Git hooks for the current checkout. `one mise` and `one hk` continue to forward tool commands. Language preferences are stored locally. diff --git a/apps/docs/content/docs/en/manifest.md b/apps/docs/content/docs/en/manifest.md index 48a594d8..0bc8636b 100644 --- a/apps/docs/content/docs/en/manifest.md +++ b/apps/docs/content/docs/en/manifest.md @@ -1,177 +1,81 @@ --- title: What is one.manifest.json -description: "The workspace ledger: who writes it, when to edit it, and what drift means." +description: The workspace's project registry, environment configuration, and local development settings. --- -Every One CLI workspace root has a `one.manifest.json`. This page explains what it does, who should edit it, and when you should leave it alone. +Every One CLI workspace has a `one.manifest.json` at its root. It records the workspace identity, projects, environments, and environment-variable source. Commands use it to find projects and select their toolchains. -**For**: anyone seeing the manifest for the first time, and anyone trying to understand where workspace state comes from. - -**You will learn**: the manifest is the workspace **source of truth**, and when you, versus One CLI, should modify it. - -## One Sentence Definition - -`one.manifest.json` is the workspace **ledger**. It records which projects exist, which templates created them, which backend each domain uses (`env`, `deploy`, `container`), and which environments exist. - -Its presence means "this is a One CLI workspace". Other commands use it to decide whether the current directory belongs to a workspace. - -## What It Stores - -The block below uses `jsonc` so the fields can be explained inline. The real `one.manifest.json` file is still strict JSON; do not paste the `//` comments into the actual file. +## Example -```jsonc +```json { - "version": 1, // Manifest schema version written by One CLI - "workspace": { // Workspace identity - "id": "demo-app-2bb61e", // Stable workspace ID generated at creation time - "name": "demo-app" // Workspace name, usually from the directory or --name - }, - "environments": { // Environments known to the workspace - "names": ["dev", "preview", "prod"], // Default environment names for new workspaces - "default": "dev" // Default environment when --env is omitted - }, - "domains": { // Workspace-level domain defaults - "env": { // Secrets / environment variable backend - "kind": "infisical", // Env backend: dotenv or infisical - "config": { // Backend-specific env config - "keys": ["VITE_API_URL", "VITE_PUBLIC_SITE"], // Declared workspace-level key names; values never enter the manifest - "projectId": "86c73b57-5d1b-4f99-90dc-5d0c8ee0e823", // Infisical project ID - "projectName": "demo-app", // Project name inside Infisical - "rootPath": "/" // Root path inside Infisical - } - }, - "deploy": { // Workspace-level deploy default - "kind": "kustomize", // Default deploy backend - "config": { // Kustomize backend config - "namespace": "demo-app-2bb61e", // Optional Kubernetes namespace; defaults to workspace.id when omitted - "kustomizationPath": "kustomize/overlays/prod" // Kustomize overlay directory used by one deploy render/apply - } - } + "version": 1, + "workspace": { "id": "demo-app-2bb61e", "name": "demo-app" }, + "environments": { + "names": ["dev", "preview", "prod"], + "default": "dev" }, - "projects": [ // Project registry for this workspace + "domains": { "env": { "kind": "dotenv" } }, + "projects": [ { - "name": "web", // Project name used by one env / one deploy -p - "templateId": "nextjs-app", // Template ID used to create this project - "relativeDir": "apps/web", // Project path relative to the workspace root - "toolchain": "node", // Project toolchain: node, go, etc. - "buildVersion": "0.1.0", // Default build version read by container / deploy commands - "packageManager": "pnpm", // Package manager for Node projects - "domains": { // Project-level domain overrides - "container": {}, // Empty object enables container builds and inherits profile defaults - "deploy": { "kind": "kustomize" }, // This project deploys with kustomize - "dev": { "command": "pnpm run dev" } // Command used by one dev - } - }, - { - "name": "spa", // Second project: static frontend app - "templateId": "react-spa", // React SPA template - "relativeDir": "apps/spa", // Frontend project directory - "toolchain": "node", // Node toolchain - "buildVersion": "0.1.0", // Current default build version - "packageManager": "pnpm", // Install dependencies with pnpm install - "domains": { // Only override what differs from workspace defaults - "deploy": { - "kind": "rustfs", // This project uses object storage instead of workspace kustomize - "config": { "bucket": "demo-app-2bb61e" } // Object storage bucket; often defaults to workspace.id - }, - "dev": { "command": "pnpm run dev" } // Command used by one dev + "name": "web", + "templateId": "react-spa", + "relativeDir": "apps/web", + "toolchain": "node", + "buildVersion": "0.1.0", + "packageManager": "pnpm", + "domains": { + "env": { "path": ".env", "inherits": true, "keys": ["API_URL"] }, + "dev": { "command": "pnpm dev" } } }, { - "name": "api", // Backend service - "templateId": "go-api", // Go API template - "relativeDir": "services/api", // Go service directory - "toolchain": "go", // Go toolchain - "buildVersion": "0.1.0", // Current default build version - "domains": { - "container": {}, // Enable container builds - "deploy": { "kind": "kustomize" }, // This service deploys to Kubernetes - "dev": { "command": "go run ./cmd/server" } // Go dev command used by one dev - } + "name": "api", + "templateId": "go-api", + "relativeDir": "services/api", + "toolchain": "go", + "domains": { "dev": { "command": "go run ./cmd/server" } } } ] } ``` -Important fields: +The real file is strict JSON; comments and unknown fields are rejected. + +## Fields | Field | Meaning | |---|---| -| `version` | Manifest schema version written and read by One CLI | -| `workspace` | Workspace identity (`id` + `name`). Infisical auto-binding uses `name` when creating an Infisical project | -| `environments` | Environment names and default env. Shared by secrets, `one deploy --env`, and `projects[].domains.deploy.config.env` | -| `domains.env` / `.deploy` / `.container` | Workspace-level backend choice: `{kind, config}`, present only when needed. `kind` is the bare backend id; `config` is backend-specific JSON | -| `projects[]` | The core project registry. Each project has `buildVersion` and optional domain overrides | -| `projects[].domains.env` | Project-level env override (`path`, `inherits`, `disabled`, `keys`); no `kind`, because it inherits the workspace backend | -| `projects[].domains.container` | Project-level container override (`kind`, `image`, `namespace`). An empty object enables container builds and uses local profile resolution | -| `projects[].domains.deploy` | Project-level deploy backend. This one has `kind` because deploy can vary by project: web -> Vercel, API -> Kustomize | -| `projects[].domains.dev` | Project-level development command. `one dev` reads `command`, such as `pnpm run dev` for Node projects or `go run ./cmd/server` for Go projects | - -`domains.deploy.config.namespace` is an optional override. When it is omitted, the Kubernetes namespace defaults to `workspace.id`, such as `demo-app-2bb61e`. Set it explicitly only when you want a fixed shared namespace or an environment-shaped name such as `demo-app-prod`. +| `version` | Manifest schema version, currently `1` | +| `workspace` | Stable `id` and display `name` | +| `environments` | Environment names and default environment | +| `domains.env` | Workspace environment source: `dotenv` or `infisical`, with optional backend-specific `config` | +| `projects[]` | Project names, paths, templates, toolchains, optional `packageManager` and `buildVersion` | +| `projects[].domains.env` | Environment overrides: `path`, `inherits`, `disabled`, and declared key names; inherits the workspace backend | +| `projects[].domains.dev` | The `command` executed by `one dev` | -> Design note: both workspace and project settings use `domains`. Env only carries project-level overrides. Deploy carries `kind` at project scope because different projects can use different deploy backends. Container can be an empty object, or carry overrides such as image / profile / namespace / kind. +For Infisical, `domains.env.config` can contain `projectId`, `projectName`, `rootPath`, and `keys`. Key values and local Profile names never belong in the manifest. Keep credentials in machine-local Profiles and values in dotenv or Infisical. -## Who Writes It +## Who writes it -| Command | Manifest change | +| Action | Change | |---|---| -| `one create` | Creates the initial manifest with workspace identity, empty `projects`, `domains.env.kind`, and `environments`; local development is available but CI is not configured | -| `one add` | Adds an item to `projects[]`, writes `projects[].domains.dev.command`, and syncs local-development files; CI and deployment remain unset | -| first `one deploy ` | Writes the selected compatible deployment target and its generated artifacts after a local connection is ready | -| `one env set` | Records key names in env config; values never go into the manifest. Infisical can lazy-bind if not already bound | -| `one container build` | Writes back `projects[i].domains.container.image` and sometimes `domains.container.config.platform` | -| `one deploy --env ` | Does **not** write the manifest; it only passes `--env` to the current deploy call | -| **You** | Rarely; see below | - -## When To Edit It Manually - -Most of the time you should not touch it. The few manual cases: - - -1. **Rename a project**: there is no `one rename` yet. Change `projects[].name` and rename the directory so the manifest and filesystem match. -2. **Remove a project**: there is no `one remove` yet. Delete the item from `projects[]` and remove the directory. - -3. **Switch deploy backend**: for example, move `web` from `aws-s3` to `vercel` by editing `projects[i].domains.deploy.kind`, then run `one configure use deploy/vercel --profile `. -4. **Adjust a local dev command**: for example, change `projects[i].domains.dev.command` from `pnpm run dev` to the project's own script. `one dev` trusts the manifest and does not auto-track later `package.json` changes. - -Fields One CLI owns: - -- `workspace.roots`: fixed to `apps/services/packages` -- Top-level `ci` / `dev`: always enabled; these fields are ignored. Project-level `projects[].domains.dev.command` is still read by `one dev` - -## What Drift Means +| `one create` | Writes workspace identity, default environments, environment source, and an empty project list | +| `one add` | Registers a project and its development command | +| `one env set` | Records declared key names; Infisical can initialize its project binding | +| `one env switch` | Changes the workspace environment source | +| `one serve` | Applies explicitly reviewed project or environment-source changes with revision checks | -Drift means the manifest and filesystem disagree. Common cases: +`one build` selects each project's build command from its toolchain. Node projects use package scripts; Go projects use `Taskfile.yml`. CI is managed separately with `one ci`. -- You manually deleted `services/user-api/` but forgot to edit the manifest. -- You pulled a teammate's branch and the filesystem / manifest are out of sync. -- Template rendering failed after the project was registered. +## Manual edits -Run the relevant per-domain command again, such as `one add`, `one container build`, or `one deploy render`. Those commands read the manifest and report what is missing. +Keep paths and names consistent when renaming or removing projects. Update `projects[].domains.dev.command` if the project's development script changes. The workspace layout remains `apps/`, `services/`, and `packages/`. -## What Does Not Belong In The Manifest +If the manifest and filesystem disagree, inspect the declared paths and restore the missing project files or fix the registry entry. Do not put business values, dependencies, caches, or build outputs in the manifest. -Do not put these in `one.manifest.json`: +## Removed deployment configuration -- Business runtime config, such as database URLs or API URLs -> use `.env` and secrets -- Project dependencies and scripts -> use that project's `package.json`, `Taskfile.yml`, or framework config -- User preferences, such as editor settings -- Temporary state, build outputs, or caches - -## Validation - -`one.manifest.json` is schema-validated. Invalid JSON or shape surfaces `MANIFEST_INVALID`; a missing or empty manifest surfaces `MANIFEST_MISSING_OR_EMPTY`, with remediation pointing to `one create` / `one add`. - -See the `MANIFEST_*` section in [Error codes](/en/docs/error-codes/). - -## Example - -Run `one create my-app && cat my-app/one.manifest.json` to see a new workspace manifest with workspace identity, the default environment set, the env backend selection, and an empty `projects` array. Then add one project: - -```bash -cd my-app -one add nestjs-api --name api -cat one.manifest.json -``` +The `deploy` and `container` domains have been removed. A manifest containing those fields returns `MANIFEST_INVALID` with a removal hint. Delete the corresponding fields manually; no migration or cleanup of existing Dockerfiles, platform configuration, or CI files is performed. -`projects[]` gains one item and its dev command. Deployment/container fields remain absent until first deploy. +Invalid JSON or unknown fields also produce `MANIFEST_INVALID`. See [error codes](/en/docs/error-codes/). diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index d395e0b0..1fd02bec 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -11,12 +11,11 @@ "create", "add", "env-vars", - "configure", + "login", "templates-cmd", - "container", "dev", + "build", "ci", - "deploy", "run", "serve", "error-codes" diff --git a/apps/docs/content/docs/en/quick-start.md b/apps/docs/content/docs/en/quick-start.md index 6627f957..bf51436f 100644 --- a/apps/docs/content/docs/en/quick-start.md +++ b/apps/docs/content/docs/en/quick-start.md @@ -58,7 +58,7 @@ You now have your first Web project running: | `pnpm install` | Downloaded the packages the project uses | | `pnpm -C apps/web dev` | Started the first Web project | -Continue by goal: use `one env` for environment variables and `one deploy` for production deploys. Container image build / push is a lower-level deploy step; only open the advanced docs when you need to control it directly. +Continue with `one env` for environment variables and `one build` to build your projects. ## Next diff --git a/apps/docs/content/docs/en/run.md b/apps/docs/content/docs/en/run.md index b367d0b2..4e74e6b4 100644 --- a/apps/docs/content/docs/en/run.md +++ b/apps/docs/content/docs/en/run.md @@ -57,7 +57,7 @@ This lets pnpm / turbo workspaces invoke `vite`, `next`, `astro`, and similar bi | `infisical` | fetch env vars from Infisical | | empty | use the provider recorded in the workspace manifest | -`--env-provider infisical` requires an `env/infisical` profile. Use `--env-provider dotenv` for offline local runs. +`--env-provider infisical` requires browser login with `one login`. Use `--env-provider dotenv` for offline local runs. ## Common errors @@ -67,10 +67,20 @@ This lets pnpm / turbo workspaces invoke `vite`, `next`, `astro`, and similar bi | `SUBPROJECT_NOT_FOUND` | pass a manifest `name` or `relativeDir` to `-p` | | `RUN_COMMAND_NOT_FOUND` | check PATH, project `node_modules/.bin`, and workspace `node_modules/.bin` | | `ENV_FILE_NOT_FOUND` | create a project `.env` or use `--env-provider infisical` | -| `INFISICAL_AUTH_MISSING` | run `one configure add env/infisical --profile --use` | +| `INFISICAL_AUTH_MISSING` | run `one login` | ## Next - [Run with env vars](/en/tutorials/run-passthrough/) - [one env](/en/docs/env-vars/) - [one dev](/en/docs/dev/) + + +## Global credentials + +```bash +one run --global --env dev --path /oss --keys OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET -- upload-assets +one run --global --env dev --path /oss --dry-run -- upload-assets +``` + +Environment and folder must be explicit. Only that folder is read; `--keys` fetches only selected variables. Dry-run does not read credentials. Global mode does not load project environments or implicitly resolve repository binaries. It preserves the current directory. Exact-value output masking is best effort, not a sandbox. diff --git a/apps/docs/content/docs/en/serve.md b/apps/docs/content/docs/en/serve.md index fcfdf4aa..874e76a3 100644 --- a/apps/docs/content/docs/en/serve.md +++ b/apps/docs/content/docs/en/serve.md @@ -1,195 +1,17 @@ --- title: one serve -description: Local Dashboard for Workspaces, Projects, and machine-level Profiles. +description: Local Dashboard for workspaces, login, and shared credentials. --- -`one serve` starts a local Dashboard bound only to `127.0.0.1` and opens a browser. The Dashboard lists Workspaces observed on this machine, lets you switch between them, stages and reviews Workspace environment Backend and Project configuration changes, manages Infisical secrets, and manages the machine-level Profiles used by `one configure`. - -Why not let AI edit the Profile files directly: they contain API keys, kubeconfig paths, and registry tokens. The risk of leaking them is higher than the value of saving a few manual inputs. `one serve` physically keeps those fields out of command-line and agent context. - -## Usage - -```bash -one serve [options] -``` - -The process blocks in the foreground. Press Ctrl-C to stop. Workspace environment Backend and Project configuration changes remain in a browser draft until the top-bar save action displays an exact diff and the user confirms. Project changes use an atomic, revision-checked Manifest patch; Backend changes use the revision-checked env switch workflow. Selecting Infisical initializes and persists the Workspace's Infisical project binding, but does not migrate existing secret values between providers. Source files remain read-only. Profile mutations share `~/.config/one/{config,credentials}.json` with `one configure`; Workspace/Project selections write only Profile names to `~/.config/one/profile-bindings.json`. - -## Arguments - -| Argument | Description | -|---|---| -| `--host ` | Bind host. Only loopback is accepted (`127.0.0.1`, `localhost`, `::1`). Non-loopback returns `SERVE_BIND_FORBIDDEN` | -| `--port ` | Listen port. Default `0` lets the kernel pick a free port | -| `--open` | Open browser after startup. Default `true`; pass `--open=false` for CI, headless, WSL, or remote SSH | -| `-o, --output ` | `json` / `yaml` / `text`; default is TTY-aware auto detection | - -## Interactive Mode - -`one serve` has no terminal wizard. Browser forms can stage the Workspace environment Backend plus allowlisted Project runtime, environment, container, and deployment settings. A single confirmation publishes the collected Manifest draft. Profile bindings remain separate machine-local saves. When the Workspace uses `env/infisical`, the Dashboard can list key names and create, reveal, update, or delete one remote value at a time. - -For local human setup, run `one serve`. Scripts, CI, and agents can use `--open=false` to receive the plain loopback URL and call the API directly. Because the API can read and mutate sensitive configuration, do not run it on a shared machine with untrusted local processes. - -## Workspace Discovery And Persistence - -One CLI records a Workspace in the machine-local list in two cases: - -- after `one create` completes successfully; -- when `one serve` runs from the Workspace root or any descendant directory. - -The XDG-aware registry lives at `~/.config/one/workspaces.json`. It stores only a local entry ID, Manifest Workspace ID, name, canonical absolute root, and observation timestamps. It does not copy Projects, Backend settings, Profiles, or credentials. An unavailable directory remains visible as missing; Forget removes only the local registration and never deletes the directory, Manifest, Profiles, or credentials. - -Running `one serve` outside a Workspace still opens the historical list. The Dashboard selects the launch Workspace first, or the most recently seen ready Workspace when there is no current one. - -## Environment Selection And Local Storage - -The Dashboard selector exposes exactly Development (`?env=dev`), Preview (`?env=preview`), and Production (`?env=prod`); unknown UI query values normalize to Development. Selecting one does not add it to the Manifest or upgrade the Manifest schema. The core/API store can also represent safe custom IDs such as `staging` when another CLI/API workflow supplies them. - -Global Settings hides the environment selector because Profile definitions and CRUD are machine-global, not environment-scoped. Links preserve the query so returning to a Workspace or Project keeps its previous binding context. - -```text -~/.config/one/ -├── config.json # Profile names, non-secret fields, defaults, legacy bindings -├── credentials.json # Profile credentials -├── profile-bindings.json # v1: canonical root + environment -> Profile names only -└── workspaces.json # observed Workspace registry -``` - -`profile-bindings.json` is a machine-local v1 store written as `0600` with atomic replacement. Its canonical-root key keeps two copies of the same repository independent even if both copies contain the same Manifest Workspace ID. It contains no credential values and never writes inside either repository. - -For a `(domain, backend)`, effective Profile resolution is: - -1. one-shot `--profile` flag; -2. Project + environment binding; -3. Workspace + environment binding; -4. legacy Project binding in `config.json`; -5. legacy Workspace binding in `config.json`; -6. machine default. - -## Output - -After binding, stdout emits one startup envelope and then blocks: - -```json -{ - "schema": "one-cli/serve/v2", - "status": "listening", - "url": "http://127.0.0.1:54321/", - "host": "127.0.0.1", - "port": 54321 -} -``` - -The startup URL contains no login information and the API does not use a session token. The service disappears when the process exits. If another `one serve` process later reuses the same port, the old URL points to that new local process. - -## Security Model - -`one serve` owns profile files, and profile files own credentials, so this local service is a sensitive interface. It trusts the machine boundary and performs no session-level authentication; any local process that can reach the loopback port can call the API. These defenses remain in place: - -| Layer | Threat blocked | Behavior | -|---|---|---| -| Host header check | DNS rebinding, where an attacker domain resolves to `127.0.0.1` | `Host` must match the bound `127.0.0.1:` or `localhost:`, otherwise `421 Misdirected Request` | -| Origin check for mutations | Cross-origin POST / script requests | POST/PUT/DELETE `Origin` must equal the service origin, otherwise `403 Forbidden` | -| Typed repository publishers | Stale or over-broad repository writes | Project patches and env Backend switches use separate allowlisted endpoints; the exact base revision must match or `SERVE_MANIFEST_CONFLICT` is returned | -| Legacy route boundary | Stale clients attempt former settings PUT routes | Former mutation paths return `409 SERVE_REPOSITORY_READ_ONLY` | - -Credentials are **masked by default**. `GET /api/configure*` returns values such as `clientSecret: "********"`, `accessKeySecret: "********"`, and `password: "********"`. The UI's reveal button calls `?reveal=1` to fetch cleartext. Infisical lists contain key names only; a single value is retrieved on demand with `Cache-Control: no-store` and kept out of SWR caches. Workspace/Project projections expose only a resolved Profile name and source, never Profile fields or credentials. - -Out of scope: - -- Multi-user access -- `0.0.0.0` / LAN exposure; `SERVE_BIND_FORBIDDEN` refuses it -- Live push when external processes edit profile files; refresh the browser after `one configure ... add` - -## Examples - -### Default: Random Port + Auto-open Browser - ```bash one serve -# profile UI started: http://127.0.0.1:54321/ -# Browser opens automatically; Ctrl-C exits -``` - -### CI / Headless / WSL: Print URL Only - -```bash -one serve --open=false -``` - -### Fixed Port For Testing Or Screenshots - -```bash -one serve --port 17900 +one serve --port 0 --open=false ``` -### Container / Remote SSH - -`one serve` binds to `127.0.0.1`. For a remote machine, use SSH port forwarding: - -```bash -# remote -one serve --open=false --port 17900 - -# local -ssh -L 17900:127.0.0.1:17900 remote-host -# Open the URL printed on the remote side, replacing the host with 127.0.0.1 -``` - -Do not try `--host 0.0.0.0`; it is rejected with `SERVE_BIND_FORBIDDEN`. - -## REST API - -The web UI uses these same routes. All routes require a matching Host header; mutating routes also require a matching Origin header. No token is required. - -| Method | Path | Meaning | Response schema | -|---|---|---|---| -| `GET` | `/api/configure` | All profile sections | `one-cli/serve-configure-config/v1` | -| `GET` | `/api/configure/{domain}/{backend}` | One section; `?reveal=1` returns cleartext | `one-cli/serve-configure-section/v1` | -| `POST` | `/api/configure/{domain}/{backend}` | Upsert body `{name, profile, use?}` | `one-cli/serve-configure-upsert/v1` | -| `DELETE` | `/api/configure/{domain}/{backend}/{name}` | Remove profile | `one-cli/serve-configure-remove/v1` | -| `PUT` | `/api/configure/{domain}/{backend}/default` | Set default profile with body `{name}` | `one-cli/serve-configure-use/v1` | -| `GET` | `/api/workspaces` | Machine-local Workspace list and live status | `one-cli/workspaces/v1` | -| `DELETE` | `/api/workspaces/{entryId}` | Forget a registration without deleting the Workspace | No body | -| `GET` | `/api/workspaces/{entryId}/overview` | Selected Workspace and Project overview | `one-cli/workspace-overview/v1` | -| `GET` | `/api/workspaces/{entryId}/profile-bindings/env?env={environment}` | Effective Workspace env Profile name/source | `one-cli/workspace-profile/v1` | -| `PUT` | `/api/workspaces/{entryId}/profile-bindings/env?env={environment}` | Select/unselect Workspace env Profile; body `{profile}` | `one-cli/workspace-profile/v1` | -| `PUT` | `/api/workspaces/{entryId}/environment/backend?env={environment}` | Revision-checked env Backend switch; body `{revision, backend}` | `one-cli/workspace-profile/v1` | -| `POST` | `/api/workspaces/{entryId}/environment/backend/initialize?env={environment}&project={name?}` | Repair a missing Infisical project binding | `one-cli/workspace-profile/v1` | -| `GET` | `/api/workspaces/{entryId}/projects/{name}?env={environment}` | Project/config projection, Manifest revision, and effective Profile names | `one-cli/workspace-project/v1` | -| `PUT` | `/api/workspaces/{entryId}/projects/{name}/profile-bindings/{domain}?env={environment}` | Select/unselect Project Profile; body `{profile}` | `one-cli/workspace-project/v1` | -| `PUT` | `/api/workspaces/{entryId}/manifest` | Apply reviewed typed Project patches; body `{revision, changes}` | `one-cli/workspace-manifest-apply/v1` | -| `GET/POST` | `/api/workspaces/{entryId}/secrets?env={environment}&project={name?}` | List direct key names / create one Infisical value | `one-cli/env-list/v1` / `one-cli/env-set/v1` | -| `GET/PUT/DELETE` | `/api/workspaces/{entryId}/secrets/{key}?env={environment}&project={name?}` | Reveal, update, or delete one Infisical value | `one-cli/env-get/v1`, `one-cli/env-set/v1`, or `one-cli/env-delete/v1` | -| `GET/PUT` | `/api/workspace/profile-bindings/env?env={environment}` | Launch-Workspace alias of the Workspace binding routes | Same as plural route | -| `PUT` | `/api/workspace/environment/backend?env={environment}` | Launch-Workspace alias of the Backend switch route | `one-cli/workspace-profile/v1` | -| `POST` | `/api/workspace/environment/backend/initialize?env={environment}&project={name?}` | Launch-Workspace alias of the binding repair route | `one-cli/workspace-profile/v1` | -| `GET` | `/api/workspace/projects/{name}?env={environment}` | Launch-Workspace Project projection alias | `one-cli/workspace-project/v1` | -| `PUT` | `/api/workspace/projects/{name}/profile-bindings/{domain}?env={environment}` | Launch-Workspace Project binding alias; body `{profile}` | `one-cli/workspace-project/v1` | - -Plural Workspace routes accept only the opaque `entryId`. The server resolves its root from the registry and revalidates the Manifest before every read or mutation; a client-supplied `root` never selects a filesystem path. Manifest publication is a typed patch, not a replacement document. Secret folder paths are derived from the selected Workspace/Project; the browser cannot submit an arbitrary path. Sending an empty Profile string removes that direct binding and restores fallback resolution. - -Former Project/Environment/Deploy/Container settings PUT paths under both `/api/workspace/...` and `/api/workspaces/{entryId}/...` remain registered for stale clients, but always return `409 SERVE_REPOSITORY_READ_ONLY`; repository writes use the revision-checked `/manifest` and `/environment/backend` routes. If copied Workspaces leave two live roots with one Manifest ID, both remain listed as conflicts: inspection is allowed and mutations return `409 Conflict` until the registry conflict is resolved. - -Legal `(domain, backend)` values include `env/infisical`, `env/dotenv`, `deploy/aws-s3`, `deploy/aliyun-oss`, `deploy/tencent-cos`, `deploy/minio`, `deploy/rustfs`, `deploy/r2`, `deploy/kustomize`, `deploy/vercel`, `deploy/cloudflare`, `deploy/edgeone`, and `container/docker`. Other combinations return 404. - -Probe example: - -```bash -curl -s "http://127.0.0.1:/api/configure" | jq '.config | keys' -``` +The server only binds to loopback addresses. The sidebar contains Workspaces, Shared credentials, and Settings. All pages share the current Infisical account. Settings supports browser login, pending state, reopening, cancellation, logout, and custom instances. -## Common Errors +Shared credentials can initialize a default location, create a Secret Manager project, or select an existing project, then browse its environments and folders. Lists contain metadata only. Reveal, copy, edit, and delete are explicit actions; remote writes are immediate and deletion identifies the complete target scope. -| Code | Recovery | -|---|---| -| `SERVE_PORT_BUSY` | Choose another port, or use `--port 0` | -| `SERVE_BIND_FORBIDDEN` | Bind only to loopback; use SSH tunneling for remote access | -| `SERVE_PAYLOAD_INVALID` | POST/PUT body is invalid JSON or missing a required field such as `name` or `profile` | -| `SERVE_MANIFEST_CONFLICT` | Reload the Workspace and review the current Manifest before recreating the draft | -| `SERVE_REPOSITORY_READ_ONLY` | Use the typed `/manifest` draft flow; the requested legacy route is not writable | -| `PROFILE_FILE_INVALID` | Repair the named local Profile file (`config.json`, `credentials.json`, or `profile-bindings.json`) | -| `PROFILE_IN_USE` | Choose **Automatic** for every Workspace/Project environment binding that references the Profile, then delete it | -| `PROFILE_BACKEND_INVALID` | URL `(domain, backend)` is not a legal pair | +Workspace overview shows each build command and its configuration source. Workspace Infisical project bindings and project configuration use one reviewed Manifest draft. Remote values never enter the Manifest draft or repository. Browsing an environment does not change the default. -Full table: [Error codes](/en/docs/error-codes/). +See [login and local settings](/en/docs/login/) for CLI usage and security boundaries. diff --git a/apps/docs/content/docs/en/templates.md b/apps/docs/content/docs/en/templates.md index aafd9c57..dfe49762 100644 --- a/apps/docs/content/docs/en/templates.md +++ b/apps/docs/content/docs/en/templates.md @@ -97,3 +97,23 @@ one add ts-library --name shared Run `one templates -o json` for full template metadata, or run `one add` interactively. The picker includes category and one-line descriptions. You can also pick one of the recommended combos above, get it running, and change course once you know more. + +## Template dependencies and Electron workspaces + +Node templates do not copy pre-generated lockfiles. `one dev` creates or updates the +repository's root lockfile when needed; commit it to Git. `one build` validates an +existing lockfile without rewriting it. + +`electron-app` requires a pnpm workspace. It remains one One project, while its main, +UI, and preload packages join the root `pnpm-workspace.yaml` and share the root lockfile. +Package names use the project name as their scope, for example `@desktop/electron`, +`@desktop/ui`, and `@desktop/preload`, so multiple desktop apps can coexist. + +The template follows the root package-manager version, registry, and mirror settings. +Existing build-script policies are preserved; configurations without a policy receive +the bundled template defaults. Explicit denials of Electron's installation script +must be adjusted at the root. For concurrent desktop development, set a different +`ELECTRON_RENDERER_PORT` in each project's environment. + +These rules apply to newly generated projects. Existing Electron projects are not +automatically rewritten or migrated. diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md index c6adaa21..371d1560 100644 --- a/apps/docs/content/docs/zh/add.md +++ b/apps/docs/content/docs/zh/add.md @@ -3,7 +3,7 @@ title: one add description: 往工作区里加一个模板化项目。 --- -工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。用户的 `hk.pkl` 保留不变。旧工作区可先通过 `one configure hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 +工作区已启用 hk 时,`one add` 会同步更新语言检查:Go 加入格式检查,JS/TS 根据项目工具加入 lint 和格式检查。用户的 `hk.pkl` 保留不变。旧工作区可先通过 `one init hooks` 启用,详见 [`one hk`](/zh/docs/hk/)。 `one add` 选择技术栈,生成一个可本地开发的项目并登记到 manifest。CI 和部署默认都保持未配置。 @@ -17,7 +17,7 @@ description: 往工作区里加一个模板化项目。 ## 用法 ```bash -one add [template-id] --name [--deploy-provider ] [options] +one add [template-id] --name [options] ``` ## 参数 @@ -27,7 +27,6 @@ one add [template-id] --name [--deploy-provider ] [optio | `template-id` | 模板 ID(如 `nestjs-api`);不传走交互式选择 | | `-n, --name` | 项目名(必填,非交互模式) | | `-y, --yes` | 非交互模式 | -| `--deploy-provider ` | 显式选择 deploy 后端(必须在模板的 compat 列表里) | | `-o, --output ` | `json` / `yaml` / `text` | 首次添加 JS/TS 项目时初始化 Node monorepo,新工作区默认使用 pnpm;已有 Node 工作区沿用其包管理器。首次添加 Go 模块时初始化根 `go.work`,从第一个模块开始维护 `use` 成员。Go 与 Node 配置可以共存,后续添加只增量登记。已有 `go.work` 的注释、`replace`、`toolchain` 和外部成员会保留;配置冲突会在写入前报告。 @@ -127,6 +126,6 @@ one add nestjs-api --name user-api --yes -o json | jq - 检查 `one.manifest.json#projects[]` 确认项目登记 - Agent 文档和本地开发配置会由 `one add` 同步 -- 下一步运行 `one dev `;需要部署时再运行 `one deploy ` +- 下一步运行 `one dev ` 开发,使用 `one build ` 构建 - 如需持续集成,单独运行 `one ci enable ` 生成 GitHub Actions 工作流 - `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one run -- go mod tidy`。 diff --git a/apps/docs/content/docs/zh/ai-native.md b/apps/docs/content/docs/zh/ai-native.md index 72d82469..093d5d69 100644 --- a/apps/docs/content/docs/zh/ai-native.md +++ b/apps/docs/content/docs/zh/ai-native.md @@ -107,8 +107,8 @@ One CLI 可以管理 env、container、deploy 等机器级配置,但 agent 不 推荐边界: -- `one.manifest.json` 记录可审查的 Workspace/Project/Backend 配置;本机 Profile 名永远不进 Manifest -- `one configure` 管理机器 Profile;`one serve` 在 `127.0.0.1` 打开 Profile 值、环境感知本机绑定与经审阅且带 revision 校验的 Backend/Project 配置草稿界面,源码和非白名单 Manifest 字段保持只读 +- `one.manifest.json` 记录可审查的 Workspace/Project/Backend 配置;密钥值与会话令牌不进 Manifest +- `one login` 管理系统钥匙串中的单一浏览器会话;`one serve` 提供账号设置、共享凭据元数据和经审阅的 Manifest 草稿。 - `.env*`、私钥、云厂商 token 不进 Git,也不写进 agent 可复用文档 - agent 可以读取结构化状态、执行缺失依赖安装和项目生成,但涉及发布、删除、覆盖凭据时应回到团队策略或人工确认 diff --git a/apps/docs/content/docs/zh/build.md b/apps/docs/content/docs/zh/build.md new file mode 100644 index 00000000..46d197a3 --- /dev/null +++ b/apps/docs/content/docs/zh/build.md @@ -0,0 +1,43 @@ +--- +title: one build +description: 构建全部项目或指定项目。 +--- + +`one build` 自动准备工具和应用依赖,再逐个执行项目构建任务。 + +```bash +one build +one build web +one build apps/web +one build -p web --env prod +one build --dry-run -o json +``` + +不指定项目时,构建 manifest 中所有可构建项目;从项目子目录执行也保持这个行为。项目名或工作区相对路径只选择该项目,不自动构建它依赖的其他本地项目。 + +## 构建命令 + +Dashboard 的项目概览会展示实际构建命令和配置来源。该字段只读;修改项目中的 `package.json` 或 `Taskfile.yml` 后,刷新页面即可看到更新。 + +- Node:读取当前 `package.json`,使用工作区包管理器(pnpm / npm / yarn / bun)执行 build 脚本。 +- Go:使用项目 `Taskfile.yml` 中的 `task build`。Go API 模板生成 `bin/server`;Go 库模板使用 `go build ./...` 编译包。旧的 Go 库可手动给 Taskfile 补上该任务。 + +Go 项目必须有 Taskfile。全量构建跳过没有 build 任务的项目,记录 `no-build-task`;显式选择这类项目时返回 `RUNTIME_TASK_NOT_FOUND`。配置损坏或必要文件缺失会在准备依赖前报错。没有任何构建任务的工作区也会报错。产物位置由项目自己的构建脚本决定。 + +## 执行顺序 + +全量构建串行执行,先构建本地 Node 依赖,再构建使用它们的项目。依赖来源为 dependencies、devDependencies、optionalDependencies,按 package.json 的 name 匹配;file: / link: 依赖按目录匹配。无依赖约束的项目保持 manifest 遍历顺序。重复包名和循环依赖在执行前报错。Go 的包依赖由 Go 工具链处理。 + +首个构建失败后停止,剩余任务记录为 `not_run`,保留失败子进程的退出码。Ctrl+C 停止当前构建及其子进程。日志带项目名前缀。 + +## 工具、依赖与环境变量 + +沿用 `one dev` 的 mise/builtin 自动选择与依赖准备方式,也覆盖没有 dev 命令的库项目。Node 依赖在工作区根目录统一安装一次。每个构建通过 `one run` 使用项目环境变量和 PATH,在项目目录执行。`--env` 指定环境,省略时使用 manifest 默认环境。 + +`--dry-run` 展示排序后的命令、目录和跳过项目,不安装工具或依赖、不读取密钥、不联网、不写文件。 + +## 输出 + +`-o json` / `-o yaml` 预览返回 `one-cli/build-plan/v1`,实际执行返回 `one-cli/build-result/v1`。过程日志写入 stderr,stdout 保持可解析。结果包含每个项目的状态、命令、耗时和退出码。依赖准备失败时返回错误,构建任务保持 `not_run`。 + +自定义命令使用 [`one run`](/zh/docs/run/)。 diff --git a/apps/docs/content/docs/zh/cli-overview.md b/apps/docs/content/docs/zh/cli-overview.md index a434bd53..5a164bde 100644 --- a/apps/docs/content/docs/zh/cli-overview.md +++ b/apps/docs/content/docs/zh/cli-overview.md @@ -1,213 +1,21 @@ --- -title: 命令总览 -description: one 顶层命令、常用子命令、输出模式和 agent 自动化契约速查。 +title: CLI 总览 +description: One CLI 日常命令和高级入口。 --- -`one cli` 是一个单文件二进制。它负责创建 workspace、添加项目、管理环境变量 / endpoint profile、执行本地开发 / 容器 / 部署流程,并为 agent / CI 提供稳定的 JSON 输出。 - -**适合读这页的人**:刚装好 one cli 想知道有哪些命令;记不清某个 flag 的人; - -**读完会**:知道每个公开命令的一句话用途、最小例子、常用子命令,以及该跳到哪一页继续看细节。 - -## 顶层命令速查 - -| 命令 | 用途 | 最小例子 | -|---|---|---| -| `one create` | 创建新 workspace | `one create my-app` | -| `one add` | 交互式或从内置模板添加项目 | `one add` | -| `one templates` | 查看可用模板 | `one templates` | -| `one env` | 管理 workspace 的 dotenv / Infisical 环境变量 | `one env list` | -| `one container` | 查看、构建、推送 Dockerfile-driven 镜像 | `one container info` | -| `one dev` | 并行启动所有项目的本地开发进程 | `one dev` | -| `one deploy` | 按 project 派发 kustomize / S3-compatible / Vercel / Cloudflare / EdgeOne 部署 | `one deploy --dry-run` | -| `one ci` | 查看或管理可选的持续集成 | `one ci` | -| `one run` | 注入项目 `.env` 后执行任意命令 | `one run -- npm test` | -| `one hk` | 工作区检查、显式修复和 Git hooks | `one hk check --all` | -| `one configure` | 配置机器级 endpoint profile | `one configure` | -| `one serve` | 启动本地 Workspace、Project 与 Profile Dashboard | `one serve` | - -## 创建 workspace - -```bash -one create [dir] [--name ] [--env-provider dotenv|infisical] [--yes] -``` - -`[dir]` 是目标目录,工作区名称默认取 `basename(dir)`。create 只创建空工作区,默认使用本地 dotenv 和 `one dev`;不配置 CI、不问项目、不问部署。 - -详见 [`one create`](/zh/docs/create/)。 - -## 添加项目 - -```bash -one add # 进入交互界面进行选择 -one templates # 查看有哪些模板 -one add --name [--yes] # 直接添加某个技术栈 -``` - -直接 `one add` 会按目录分成应用、服务、共享库三类,再询问技术栈和项目名;文档站归在应用中。它不配置 CI,也不询问部署。普通 add 保持部署未配置,直到 `one deploy `;`--deploy-provider` 只作为高级自动化选项保留。 - -详见 [`one add`](/zh/docs/add/)。 - -## 模板 - -```bash -one templates -one templates -o json -``` - - `one templates` 会列出内置模板。agent / CI 建议使用 `-o json` 读取模板 ID、分类、toolchain 和兼容 backend。 - -详见 [`one templates`](/zh/docs/templates-cmd/)。 - -## 环境变量 - -```bash -one env get [--env ] [-p ] -one env set [VALUE] [--env ] [-p ] -one env list [--env ] [-p ] -one env pull [--env ] [-p ] [--force] [--dry-run] -``` - -`one env` 操作 workspace 当前选择的 env 后端。`dotenv` 读写本地 `.env` overlay;`infisical` 支持远端 get / set / list / pull。`--env` 选择 dev / staging / prod 等环境;`-p / --project` 可按 manifest 里的项目名或相对路径选项目。 - -详见 [`one env`](/zh/docs/env-vars/)。 - -## 本机连接 - -工作区的检查与提交 hook 使用 `one configure hooks` 配置,详见 [`one hk`](/zh/docs/hk/)。下面的连接命令用于机器级服务配置。 - -```bash -one configure -one configure add -one configure add --profile [backend flags...] [--use] -one configure list [pair] -one configure current [pair] -one configure show --profile [--reveal] -one configure use --profile -one configure remove --profile -one configure locale [auto|zh-CN|en-US] -one configure open -``` - -`configure` 管理本机连接和偏好设置。没有连接时,无参调用进入建立连接向导;已有连接时显示简洁概览。`show` / `use` / `remove` 可在终端选择,脚本仍可显式传服务 ID 和 `--profile`。密钥只保存在本机,不写入工作区或 Git。 - -支持的 ``: - -| domain | backend | -|---|---| -| `env` | `infisical` | -| `container` | `docker` | -| `container` | `dockerhub`, `ghcr`, `acr` | -| `deploy` | `aliyun-oss`, `tencent-cos`, `aws-s3`, `minio`, `rustfs`, `r2` | -| `deploy` | `kustomize`, `vercel`, `cloudflare`, `edgeone` | - -本地 `.env` 文件不需要本机连接。 -本机连接写到 `~/.config/one/config.json` 和 `~/.config/one/credentials.json`。环境感知的 Workspace/Project 选择只保存名字,位于 `~/.config/one/profile-bindings.json`。敏感字段默认掩码,只有 `show --reveal` 会显示明文;这些文件都不会改动 `one.manifest.json`。 -添加 token 时推荐使用 `one configure open`,避免把密钥交给 AI agent。 - -## 交互模式速查 - -| 命令 | 交互模式 | -|---|---| -| `one create` | 有;无参时询问目标目录和可选工作区名称 | -| `one add` | 有;无参时选择项目类型、技术栈和项目名 | -| `one configure` | 有;无参或 `one configure add` 进入本机连接向导 | -| `one env set` | 有;隐藏输入值、选择作用域、确认覆盖;脚本显式传值 | -| `one container build` | 半交互;TTY 下缺少构建版本时可选择版本,CI 用 `--build-version` | -| `one deploy` | 首次部署询问项目、目标类别/服务和本机连接;脚本传 `--provider` / `--profile` | -| `one dev` | Node 依赖缺失时询问是否安装,否则直接启动 | -| `one ci disable` | 删除生成的工作流前先确认;拒绝时成功退出 | -| `one templates` / `one run` | 无交互式向导;通过参数控制行为 | -| `one serve` | 不是终端向导;它打开本地 Dashboard 管理 Workspace、Project 与本机连接 | - -## 本地 Web UI - -```bash -one serve [--host 127.0.0.1] [--port 0] [--open=false] -``` - -启动仅绑定 loopback 的本地 HTTP 服务,用浏览器手工编辑 `env / deploy / container` Profile、选择环境感知的本机绑定,并在发布前审阅类型化的 Workspace Backend 或 Project 配置草稿及 revision 校验。Workspace 源码与非白名单 Manifest 字段保持只读。这个入口会处理 API key、kubeconfig path、registry token 等敏感字段,设计上是给人类使用,不给 AI agent 直接读写凭据。 - -详见 [`one serve`](/zh/docs/serve/)。 - -## 容器 - -```bash -one container info -one container build [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -one container push [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -``` - -`one container` 读取每个项目的 Dockerfile 和 manifest 里的 container 配置。裸 `build` 默认本地构建 `:`;传 `--profile` 或解析到机器本地 registry 绑定/default 时,会使用 registry-qualified tag 并执行登录。`push` 需要 registry Profile,必要时会把本地镜像 retag 后推送。 - -## 本地开发 - -```bash -one dev [project] [--dry-run] -``` - -读取项目的 dev 命令并用内置 supervisor 并行启动。位置参数只启动一个项目;`--project` 为旧脚本保留。Node 依赖缺失时可确认安装并继续。 - -## 部署 - -```bash -one deploy [project] [--provider ] [--profile ] [--dry-run] -``` - -首次部署只展示当前仓库已经实现、且与技术栈兼容的部署目标,然后询问本机连接。选择“稍后配置”会成功退出且不修改工作区;后续部署复用项目已保存的目标。 - -`--env ` 一次性覆盖目标环境;`--dry-run` 打印 docker / kubectl / s3 / platform CLI 计划,不触碰远端。 - -## 持续集成 - -```bash -one ci -one ci enable [project] -one ci sync [project] -one ci disable [project] -``` - -持续集成是可选能力,`one create` 和 `one add` 都不会自动添加。当前版本生成 -GitHub Actions 工作流。不传 `[project]` 时处理全部项目(`sync` 只更新已经启用 -持续集成的项目)。 - -详见 [持续集成](/zh/docs/ci/)。 - -## 注入环境变量后运行 - -```bash -one run [-p ] [--env-provider dotenv|infisical] [--env ] -- [args...] -``` - -子进程总是在解析出的项目目录里执行。默认从 workspace manifest 读取 env provider,也可以用 `--env-provider` 强制走 dotenv 或 Infisical。 - -## 输出模式 - -每个命令都支持同一组通用输出参数: - -| 触发条件 | 模式 | -|---|---| -| `-o json` 或 `--output json` | 强制 JSON,2-space pretty-print | -| `-o yaml` 或 `--output yaml` | 强制 YAML,与 JSON 同 schema | -| `-o text` 或 `--output text` | 强制人类格式 | -| 默认 + pipe / 非 TTY | JSON | -| 默认 + TTY | 彩色人类格式 | - -直接打 `one templates` 会看到终端友好的输出; -agent / CI 通过 pipe 读取时默认拿 JSON。 -脚本里仍建议显式写 `-o json`,避免执行环境变化影响解析。 - -## 元命令 - -```bash -one --version -one --help -one help --all -one --help -``` - -`one --help` 只展示六个日常核心任务;`one help --all` 展示完整命令;具体 flag 以 `one --help` 为准。 - -## `one skills install` - -为选定的 coding agent 安装或刷新内置 `one-cli` skill。使用可重复的 `--agent ` 指定目标,或用 `--yes` 安装到所有检测到的 Agent。支持离线执行,无需进入工作区,可重复安装。详见 [Skills](./skills)。 +| 命令 | 用途 | +| --- | --- | +| `one create` | 创建工作区 | +| `one add` | 添加项目 | +| `one dev` | 启动开发 | +| `one build` | 执行项目构建 | +| `one env` | 查看和管理项目环境变量 | +| `one login` / `one whoami` / `one logout` | 单账号浏览器会话 | +| `one env --global` | 发现共享凭据位置与环境 | +| `one run` | 注入变量后执行命令 | +| `one serve` | 打开 Dashboard | +| `one locale` | 本机语言 | +| `one init mise` / `one init hooks` | 工作区工具配置 | +| `one ci` / `one templates` / `one skills` | 自动化与资源 | + +通过 `one help --all` 发现完整命令,执行前查看对应 `--help`。Agent 无需在 Dashboard 复制执行命令,可以自主读取帮助、列出目录与变量元数据,再用明确的环境和目录执行任务。详情见[登录与共享凭据](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/configure.md b/apps/docs/content/docs/zh/configure.md deleted file mode 100644 index a08332c3..00000000 --- a/apps/docs/content/docs/zh/configure.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -title: one configure -description: 管理部署、环境变量和镜像仓库所需的本机连接与偏好设置。 ---- - -`one configure` 管理**本机连接和偏好设置**;`one configure mise` 生成工作区工具配置,`one configure hooks` 生成 hk 检查并安装本地 Git 启动器。连接密钥只保存在本机,不写入工作区或 Git。 - -## 用法 - -```bash -one configure -one configure add -one configure add --profile [backend flags...] [--use] -one configure list [pair] -one configure current [pair] -one configure show --profile [--reveal] -one configure use --profile -one configure remove --profile -one configure locale [auto|zh-CN|en-US] -one configure open -one configure mise [--dry-run] [--node-version ] [--go-version ] -one configure hooks [--dry-run] -``` - -没有连接时,无参 `one configure` 进入建立连接向导;已有连接时显示简洁概览。`show` / `use` / `remove` 在终端可直接选择已有连接;脚本仍显式传 `` 和 `--profile`。 - -## hooks 工作区提交检查 - -新工作区已经配置 hk。克隆后运行 `one configure hooks` 安装当前 checkout 的钩子;旧工作区可先用 `one configure hooks --dry-run -o json` 预览迁移。配置过程不下载工具,保留用户自定义检查;具体规则和迁移限制见 [`one hk`](/zh/docs/hk/)。 - -## 交互模式 - -本地人工配置推荐用交互式向导: - -```bash -one configure -one configure add -``` - -向导先选择要连接的服务,再询问连接名称和该服务需要的字段。自动化命令中继续使用稳定服务 ID;敏感字段使用密码式输入。 - -脚本和 CI 不应等待交互式向导;请显式传服务 ID、连接名称(`--profile`)和服务参数。 - -## 支持的 pair - -| pair | 用途 | -|---|---| -| `env/infisical` | Infisical site URL + Universal Auth client id / secret | -| `deploy/aliyun-oss` | 阿里云 OSS | -| `deploy/tencent-cos` | 腾讯云 COS | -| `deploy/aws-s3` | AWS S3 | -| `deploy/minio` | 自部署 MinIO | -| `deploy/rustfs` | 自部署 RustFS | -| `deploy/r2` | Cloudflare R2 | -| `deploy/kustomize` | Kubernetes kubeconfig + context | -| `deploy/vercel` | Vercel API token | -| `deploy/cloudflare` | Cloudflare API token | -| `deploy/edgeone` | Tencent EdgeOne Pages API token | -| `container/docker` | 通用 Docker registry host、namespace、username、password | -| `container/dockerhub` | Docker Hub username、password/token、namespace | -| `container/ghcr` | GitHub Container Registry username、PAT、namespace | -| `container/acr` | 阿里云 ACR region、username、password/token、namespace | - -`env/dotenv` 不需要 profile;它用于本地 `.env` 工作流。S3 兼容 deploy 后端共用一组 profile 字段,但每个供应商都有自己的 backend ID。 - -## 常用示例 - -```bash -one configure add env/infisical --profile work \ - --client-id "$INFISICAL_CLIENT_ID" \ - --client-secret "$INFISICAL_CLIENT_SECRET" \ - --use - -one configure add deploy/aws-s3 --profile web-prod \ - --region us-east-1 \ - --access-key-id "$AWS_ACCESS_KEY_ID" \ - --access-key-secret "$AWS_SECRET_ACCESS_KEY" \ - --use - -one configure add deploy/kustomize --profile prod-k8s \ - --kubeconfig ~/.kube/config \ - --kubeconfig-context prod \ - --use - -one configure add container/ghcr --profile ghcr \ - --namespace "$GITHUB_USER" \ - --username "$GITHUB_USER" \ - --password "$GHCR_PAT" \ - --use -``` - -## profile 解析顺序 - -命令实际使用 profile 时按这个顺序找: - -1. 命令行 `--profile ` -2. `profile-bindings.json` 中的 Project + environment 绑定 -3. `profile-bindings.json` 中的 Workspace + environment 绑定 -4. `config.json#workspaces` 中的旧 Project 绑定 -5. `config.json#workspaces` 中的旧 Workspace 绑定 -6. `~/.config/one/config.json` 里对应 `domain/backend.default` - -环境绑定按规范化 Workspace root、environment 和 `(domain, backend)` 定位,只保存 Profile 名。Dashboard UI 通过 `?env=` 只提供 `dev`、`preview`、`prod`;核心/API 也接受其他工作流传入的安全自定义 ID。全局 Settings 中的 Profile CRUD 不按环境分区。空环境保持旧解析链。 - -`one.manifest.json` 永远不保存本机 Profile 名。`one configure use ... --workspace` 和 `--project` 作为旧绑定仍兼容;需要每个环境不同选择时使用 `one serve`。 - -同名 profile 可以存在于不同 backend 下,例如 `deploy/aws-s3` 和 `deploy/kustomize` 都可以有 `prod`。 - -## 存储位置 - -```text -~/.config/one/ -├── config.json # Profile 非敏感字段、default、旧绑定 -├── credentials.json # 敏感字段:clientSecret、accessKeySecret、password -├── profile-bindings.json # v1:规范化 root + environment -> Profile 名 -└── cache/ # 短期 token 缓存 -``` - -三个 JSON 文件都是 mode `0600` 的机器本地文件;`profile-bindings.json` 只含名字。它们都不会修改或升级 `one.manifest.json`。`show` 默认掩码敏感字段,只有 `show --reveal` 会输出明文。 - -## mise 工作区工具配置 - -新建 workspace 会自动生成 mise 配置,添加项目时自动更新。日常仍使用原来的命令: - -```bash -one create my-app -y -cd my-app -one add react-spa --name web -y -one dev web -one run web -- pnpm build -``` - -旧 workspace 可一次性生成配置,之后也使用同样的日常命令: - -```bash -one configure mise --dry-run -o json -one configure mise -``` - -预览返回每个文件的 `before` / `after`,不执行 mise、不联网、不读取项目密钥。实际写入仅涉及根目录和各项目的 `.mise/conf.d/one.toml`,这些生成文件可纳入 Git。Manifest schema 保持 v1。 - -根配置固定 Node 版本(默认 `24.15.0`,再次生成沿用之前的版本),包管理器版本来自根 `package.json#packageManager`;Go 项目使用 `go.mod` 的 `go` / `toolchain` 声明。需要调整时用 `--node-version` / `--go-version` 指定完整版本。Go override 不能低于 `go.mod` 最低要求;自定义 Node engines 的兼容性需自行确认,当前尚未解析完整 npm 版本范围。 - -One 保留用户的 `mise.toml` 和自定义任务;同目录的 `mise.toml` 可以覆盖生成默认值。手改生成文件会触发 `MISE_CONFIG_CONFLICT`,应将定制内容移入用户配置,再恢复生成文件。已有冲突会在 `one add` 渲染项目之前报告;若后续磁盘写入失败,项目保留,修复错误后运行 `one configure mise` 完成配置。 - -根据项目已有能力生成 `one:dev`、`one:build`、`one:test`、`one:lint`。这些任务执行时读取当前 Manifest、package scripts 或 Taskfile;额外命令参数继续通过 `one run -- [args...]` 传递。按需使用 `one mise` 访问配置信任、诊断和任务命令,无需单独安装 mise。手动运行 mise 任务时,PATH 中的 One 必须支持生成配置的执行协议;也可设置 `ONE_BINARY_PATH` 为测试版 One 的绝对路径。 - -`ONE_RUNTIME=builtin` 可用于临时诊断,让 `one run` / `one dev` 使用机器现有工具;该模式不提供 mise 环境。移除该变量即可恢复自动选择。没有根生成配置的旧 workspace 默认使用 builtin,不会静默迁移。 - -此轮仅接入 `run`、`dev` 和创建流程。CI、部署前构建、工具锁文件生成和跨平台工具安装矩阵留待下一阶段;它们目前仍沿用原有实现。工具的精确版本声明不等于完整的跨平台 `mise.lock`。 - -## 输出 schema - -| 命令 | schema | -|---|---| -| `add` | `one-cli/configure-add/v1` | -| `list ` | `one-cli/configure-list/v1` | -| `list` | `one-cli/configure-list-all/v1` | -| `current ` | `one-cli/configure-current/v1` | -| `current` | `one-cli/configure-current-all/v1` | -| `show` | `one-cli/configure-show/v1` | -| `use` | `one-cli/configure-use/v1` | -| `remove` | `one-cli/configure-remove/v1` | -| `mise` | `one-cli/mise-config/v1` | - -## 错误恢复 - -| 错误码 | 处理 | -|---|---| -| `PROFILE_NONE_CONFIGURED` | 先跑 `one configure add --profile --use` | -| `PROFILE_NOT_FOUND` | `one configure list ` 看本机已有 profile | -| `PROFILE_BACKEND_INVALID` | 确认 profile 所在 backend 与目标 project 的 deploy/container backend 一致 | -| `PROFILE_FILE_INVALID` | 修复错误 context 指向的文件(`config.json`、`credentials.json` 或 `profile-bindings.json`) | -| `PROFILE_VERSION_UNSUPPORTED` | 升级 One CLI,或只重建不兼容的机器本地文件 | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 进一步阅读 - -- [`one serve`](/zh/docs/serve/) — 编辑 Profile 并选择环境感知的本机绑定 -- [`one env`](/zh/docs/env-vars/) — 使用 `env/infisical` profile -- [`one deploy`](/zh/docs/deploy/) — 使用 deploy profile -- [`one container`](/zh/docs/container/) — 使用 container profile diff --git a/apps/docs/content/docs/zh/container.md b/apps/docs/content/docs/zh/container.md deleted file mode 100644 index e009cafb..00000000 --- a/apps/docs/content/docs/zh/container.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: one container -description: 查看、构建、推送项目 Dockerfile 镜像。 ---- - -`one container` 操作 `one.manifest.json#projects[].domains.container` 声明的项目。当前容器后端是 Dockerfile-driven:模板提供 Dockerfile,One CLI 负责解析项目、推断镜像名、拼 registry tag,并调用 Docker。 - -## 用法 - -```bash -one container info -one container build [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -one container push [subproject] [-p ] [--build-version ] [--dry-run] [--profile ] -``` - -`[subproject]` 和 `-p / --project` 都可选一个项目,支持 manifest 里的 `name` 或 `relativeDir`。不传时对所有启用 container 的项目执行。 - -## 交互模式 - -`one container info` 和 `one container push` 不打开交互式向导。`one container build` 在 TTY 下如果没有传 `--build-version`,且无法从 manifest / Git / 项目元数据稳定推断版本,会让你选择镜像版本或输入自定义版本。 - -脚本、CI、agent 应显式传 `--build-version` 和 `--profile`,或先用 `--dry-run` 看将执行的 Docker 命令。 - -## info - -只读检查每个可构建项目的 Dockerfile、workload name 和 image override: - -```bash -one container info -o json -``` - -输出 schema:`one-cli/container-info/v2`。 - -## build - -```bash -one container build api -one container build -p services/api --build-version v0.1.0 -one container build --dry-run -``` - -默认构建本地 tag:`:`。当传 `--profile`,或本机 Profile 解析选中 registry 连接时,会拼出 registry-qualified tag:`/[namespace/]:`,并在有 username/password 时先执行 `docker login`。 - -`--build-version` 是非交互 / CI 用版本号。TTY 模式没传版本时,CLI 会从 manifest、Git 或项目元数据推断,必要时提示选择。 - -输出 schema:`one-cli/container-build/v2`。 - -## push - -```bash -one container push api --profile ghcr -one container push -p apps/web --build-version v0.1.0 --dry-run -``` - -`push` 必须能解析到项目所用 kind 的 container profile(例如 `container/ghcr`、`container/dockerhub`、`container/acr` 或通用 `container/docker`)。若 registry tag 不在本地,但匹配的本地裸 tag 存在,CLI 会先 `docker tag : /.../:`,再推送。 - -输出 schema:`one-cli/container-push/v1`。 - -## profile 解析 - -`build` / `push` 使用 Docker registry profile 的顺序: - -1. `--profile ` -2. `profile-bindings.json` 中 Project + environment 的 `container/` 绑定 -3. `profile-bindings.json` 中 Workspace + environment 的 `container/` 绑定 -4. `config.json#workspaces` 中的旧 Project 绑定 -5. `config.json#workspaces` 中的旧 Workspace 绑定 -6. `~/.config/one/config.json#container/.default` - -Container 命令使用 Manifest 默认环境(或第一个已声明环境)作为绑定 key。该 key 只在本机并按规范化 root 隔离;Profile 名永远不进 Manifest。用 `one serve` 为各环境选不同 Profile,或用 `--profile` 单次覆盖 build/push。 - -配置一次即可复用: - -```bash -one configure add container/ghcr --profile ghcr \ - --namespace "$GITHUB_USER" \ - --username "$GITHUB_USER" \ - --password "$GHCR_PAT" \ - --use -``` - -支持的 kind 包括 `container/docker`(通用 registry)、`container/dockerhub`、`container/ghcr` 和 `container/acr`。 - -## manifest 条件 - -`nestjs-api`、`go-api`、`nextjs-app` 模板默认启用 `container/docker`。库、移动端、Electron 默认不启用 container;这些项目跑 `one container` 会被跳过。 - -## 错误恢复 - -| 错误码 | 处理 | -|---|---| -| `BACKEND_NOT_ENABLED` | 当前 workspace 没有项目声明 container backend;换模板或补 manifest | -| `REGISTRY_CREDENTIAL_MISSING` | 先 `one configure add container/ --profile --use` | -| `IMAGE_TAG_NOT_FOUND` | push 前先 build,或显式传同一个 `--build-version` | -| `CONTAINER_BUILD_FAILED` | 进入项目目录直接跑 Dockerfile 构建命令看完整日志 | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 进一步阅读 - -- [构建与推送镜像](/zh/tutorials/container-build-push/) — 端到端流程 -- [`one configure`](/zh/docs/configure/) — 配置 `container/docker` profile -- [`one deploy`](/zh/docs/deploy/) — kustomize 部署会自动构建 / 推送镜像 diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index 4af06986..c8fe8ec5 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -50,20 +50,15 @@ one create my-app --yes --env-provider infisical | 工具环境 | mise | 自动生成根 `.mise/conf.d/one.toml`;后续 `one add` 自动生成项目配置 | | Git 检查 | hk | 创建共享检查配置并安装本地提交钩子;后续 `one add` 增量加入语言检查 | -创建和添加项目只生成配置,不下载工具。首次运行时 One 优先使用兼容的系统 mise,否则按需下载并托管;本地没有可用版本时需要联网,正常命令保持不变。工具版本与已有 workspace 的启用方式见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 +创建和添加项目只生成配置,不下载工具。首次运行时 One 优先使用兼容的系统 mise,否则按需下载并托管;本地没有可用版本时需要联网,正常命令保持不变。工具版本与已有 workspace 的启用方式见 [`one init mise`](/zh/docs/login/#mise-工作区工具配置)。 空工作区先保持语言无关:首次添加 Go 模块时创建根 `go.work` 并登记该模块;首次添加 JS/TS 项目时创建根 `package.json` 和 `pnpm-workspace.yaml`。后续项目增量加入,两套配置可以共存。Git hooks 从创建工作区时就由 hk 提供,纯 Go 工作区不生成 Node 配置;JS 工作区也不再依赖 Husky 或 commitlint。工作区不默认安装版本管理工具或生成 Changesets 配置,发布流程由项目按需配置。 -提交前默认只检查暂存内容,使用 `one hk fix` 显式修复。用法与自定义方式见 [`one hk`](/zh/docs/hk/)。Git 未安装或已有 hooks 配置发生冲突时,工作区仍会创建,输出的 `warnings` 会提示后续执行 `one configure hooks`。 +提交前默认只检查暂存内容,使用 `one hk fix` 显式修复。用法与自定义方式见 [`one hk`](/zh/docs/hk/)。Git 未安装或已有 hooks 配置发生冲突时,工作区仍会创建,输出的 `warnings` 会提示后续执行 `one init hooks`。 持续集成默认不配置。创建工作区不会写入 `.github/workflows/`;添加项目后如有 需要,再显式运行 `one ci enable `。 -**部署决策延后** - -create 不写部署或镜像配置,普通 `one add` 也保持未配置。第一次运行 -`one deploy ` 时,才选择兼容的部署目标和本机连接。 - ## --env-provider 语义 `--env-provider ` 显式指定 env 后端: @@ -72,16 +67,13 @@ create 不写部署或镜像配置,普通 `one add` 也保持未配置。第 one create my-app -y --env-provider infisical ``` -使用 Infisical 前建议先配置机器级 profile: +使用 Infisical 前先通过浏览器登录: ```bash -one configure add env/infisical --profile work \ - --client-id $INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \ - --client-secret $INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET \ - --use +one login ``` -`one create --env-provider infisical` 会尽量自动绑定 / 创建 Infisical project;如果当时 profile、网络或权限没准备好,工作区仍会创建成功,首次 `one env set/get/list/pull` 会再尝试一次 lazy auto-bind。 +`one create --env-provider infisical` 会尽量自动绑定 / 创建 Infisical project;如果当时登录、网络或权限没准备好,工作区仍会创建成功,首次 `one env set/get/list/pull` 会再尝试一次 lazy auto-bind。 ## 输出 diff --git a/apps/docs/content/docs/zh/deploy.md b/apps/docs/content/docs/zh/deploy.md deleted file mode 100644 index f5fdebfe..00000000 --- a/apps/docs/content/docs/zh/deploy.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: one deploy -description: 按项目派发到 kustomize、S3-compatible、Vercel、Cloudflare 或 EdgeOne 部署后端。 ---- - -`one deploy` 是按项目部署入口。第一次部署时选择兼容的部署目标和本机连接,后续部署复用项目配置。 - -## 用法 - -```bash -one deploy [project] [--provider ] [--profile ] [--env ] [--dry-run] -``` - -## 参数 - -| 参数 | 说明 | -|---|---| -| 位置参数 `project` | 只部署一个项目;支持 manifest 里的 `name` 或 `relativeDir` | -| `-p, --project ` | 为旧脚本和 CI 保留的选择参数 | -| `--provider ` | 自动化首次部署时显式指定目标 | -| `--profile ` | 本次使用指定本机连接 | -| `--env ` | 覆盖部署目标环境,同时作为环境变量注入环境 | -| `--env-provider dotenv|infisical` | 覆盖 workspace manifest 里选择的 env provider | -| `--build-version ` | 非交互 / CI 用镜像版本;主要用于 kustomize 自动构建 | -| `--dry-run` | 打印 docker / kubectl / 对象存储 / 平台 CLI 计划,不触碰远端 | - -## 交互模式 - -尚未配置部署的项目在 TTY 下依次询问: - -1. 部署哪个项目(未传时) -2. 部署目标类别 -3. 当前版本已实现且与技术栈兼容的具体服务 -4. 缺少本机连接时选择“现在配置”或“稍后配置” - -选择“稍后配置”会以 0 退出、不修改工作区,并打印准确恢复命令。脚本使用 `one deploy --provider --profile `。 - -## 后端 - -| backend | 适合项目 | 行为 | -|---|---|---| -| `kustomize` | API、SSR、需要容器的服务 | 自动 build / push 镜像,同步 overlay,然后 `kubectl apply -k` | -| `aws-s3` / `aliyun-oss` / `tencent-cos` / `minio` / `rustfs` / `r2` | 静态站 | 构建产物,确保 bucket,走 S3-compatible 协议上传 | -| `vercel` | 前端托管 | 调 Vercel CLI/API 部署 | -| `cloudflare` | Cloudflare Workers | 调 `wrangler deploy` | -| `edgeone` | EdgeOne Pages | 调 `edgeone pages deploy` | - -## 环境映射 - -| backend | `prod` 或空 | 其他环境 | -|---|---|---| -| `kustomize` | `kustomize/overlays/prod` | `kustomize/overlays/` | -| `vercel` | production deploy | preview deploy | -| `cloudflare` | `wrangler deploy` | `wrangler deploy --env=` | -| `edgeone` | production deploy | preview deploy | -| S3-compatible | deploy 目标不变 | deploy 目标不变;只影响构建时注入的 env | - -`--env` 必须存在于 `one.manifest.json#environments.names`。 - -## profile 解析 - -每个 deploy target 独立解析 profile: - -1. `--profile ` -2. `profile-bindings.json` 中 Project + environment 的 `deploy/` 绑定 -3. `profile-bindings.json` 中 Workspace + environment 的 `deploy/` 绑定 -4. `config.json#workspaces` 中的旧 Project 绑定 -5. `config.json#workspaces` 中的旧 Workspace 绑定 -6. `~/.config/one/config.json#deploy/.default` - -环境感知的 key 使用规范化 Workspace root。deploy 解析使用 Project 已配置的部署环境,没有时回退 `prod`;单次覆盖使用 `--profile`。在 `one serve` 中选择每个环境的 Workspace/Project Profile 名。Dashboard 环境来自 `?env=`,切换它不会改动部署环境或 Manifest。 - -manifest 永远不保存本机 Profile 名。`one configure use --profile --workspace` 和 `--project ` 仍可创建兼容的无环境旧绑定。 - -## 示例 - -```bash -one deploy --dry-run -one deploy web --env staging --dry-run -one deploy api --provider kustomize --profile prod-k8s --build-version v0.1.0 -``` - -## 输出 schema - -deploy 输出 schema 按 provider 分开: - -| backend | schema | -|---|---| -| `kustomize` | `one-cli/deploy-apply/v1` | -| S3-compatible | `one-cli/deploy-apply/v1` | -| `vercel` | `one-cli/deploy-apply-vercel/v1` | -| `cloudflare` | `one-cli/deploy-apply-cloudflare/v1` | -| `edgeone` | `one-cli/deploy-apply-edgeone/v1` | - -dry-run 会优先打印将执行的命令行,适合 CI 或上线前确认。 - -## 错误恢复 - -| 错误码 | 处理 | -|---|---| -| `BACKEND_NOT_ENABLED` | 非交互调用请显式指定项目和部署目标 | -| `PROFILE_NOT_FOUND` | `one configure list deploy/` 看本机已有 profile | -| `PROFILE_NONE_CONFIGURED` | 先 `one configure add deploy/ --use` | -| `ENV_UNKNOWN_ENVIRONMENT` | 把环境名加入 `manifest.environments.names`,或换成已有环境 | -| `REGISTRY_CREDENTIAL_MISSING` | kustomize 自动构建前先配置 `container/docker` profile | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 进一步阅读 - -- [第一次部署](/zh/tutorials/deploy/) — 部署一个项目 -- [多 backend 部署](/zh/tutorials/deploy-multi-backend/) — 多后端、多项目、多环境 -- [`one configure`](/zh/docs/configure/) — 配置 deploy profile -- [`one container`](/zh/docs/container/) — 镜像构建 / 推送细节 diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index c0b0460a..50059333 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -5,7 +5,7 @@ description: 启动全部可开发项目,或只启动一个项目。 `one dev` 从 manifest 读取每个项目的开发命令,并用 One CLI 内置 supervisor 运行。 -启用 mise 的 workspace 会自动在每个项目的 mise 工具环境中运行开发命令,仍然使用 `one dev` / `one dev web`,无需增加 runtime 参数。日志前缀、项目选择和整组服务的停止行为继续由原有 supervisor 负责。mise 的安装与旧项目启用见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 +启用 mise 的 workspace 会自动在每个项目的 mise 工具环境中运行开发命令,仍然使用 `one dev` / `one dev web`,无需增加 runtime 参数。日志前缀、项目选择和整组服务的停止行为继续由原有 supervisor 负责。mise 的安装与旧项目启用见 [`one init mise`](/zh/docs/login/#mise-工作区工具配置)。 ## 用法 @@ -26,7 +26,8 @@ one dev [project] [--dry-run] `one dev` 在启动服务前自动准备所选项目的工具与应用依赖,交互和非交互调用行为一致。 -- Node:在工作区根目录安装一次。已有锁文件时执行冻结安装,锁文件与项目声明不一致会失败;新工作区首次安装生成锁文件。项目声明、工具版本改变或依赖目录被清理后会重新准备。 +- Node:在工作区根目录统一准备依赖。使用 pnpm 时,先通过 pnpm 自身检查工作区的安装状态;手动 `pnpm install` 后,只要依赖与当前工作区一致,就直接复用。需要安装时执行 `pnpm install --no-frozen-lockfile`,自动同步新增项目或依赖变更。pnpm 10.14 之前的版本沿用 One 的安装缓存。其他包管理器仍使用原有锁文件策略。 +- `one build` 保留现有的严格安装策略:已有 pnpm 依赖锁文件时使用 `--frozen-lockfile`。构建发现锁文件过期后,需要先安装并审阅锁文件变更。 - Go:独立模块下载固定构建列表并补充 `go.sum`;存在 `go.work` 时由 Go 按实际包依赖解析本地成员和外部依赖,按需维护 `go.work.sum`。准备过程不自动运行 `go mod tidy` 或 `go work sync`。 - 所有准备成功后才启动 supervisor。失败保留底层错误和下载缓存,可修复后重试原命令;取消时停止准备进程。 diff --git a/apps/docs/content/docs/zh/env-vars.md b/apps/docs/content/docs/zh/env-vars.md index 2356f130..bbb24b7d 100644 --- a/apps/docs/content/docs/zh/env-vars.md +++ b/apps/docs/content/docs/zh/env-vars.md @@ -26,7 +26,7 @@ description: 多环境环境变量 — set / get / list / pull 子命令的完 ```bash one env set [VALUE] [--env ] [-p ] [--yes] -one env get [--env ] [-p ] +one env get [--env ] [-p ] --reveal one env list [--env ] [-p ] one env pull [--env ] [-p ] [--force] [--dry-run] ``` @@ -43,9 +43,9 @@ one env pull --env staging # 拉所有项目的 staging 环境变量 通用输出 flag 是 `-o / --output`,取值 `json` / `yaml` / `text`。 -> 当前没有 `one env init` 子命令。Infisical project binding 由 `one create --env-provider infisical` 自动尝试;如果 create 时 profile、网络或权限还没准备好,首次 `set/get/list/pull` 会再尝试 lazy auto-bind。 +> 当前没有 `one env init` 子命令。Infisical project binding 由 `one create --env-provider infisical` 自动尝试;如果 create 时登录、网络或权限还没准备好,首次 `set/get/list/pull` 会再尝试 lazy auto-bind。 -机器级 Infisical 凭据通过 [`one configure add env/infisical`](/zh/docs/cli-overview/#one-configure) 配,不进入 manifest。 +机器级 Infisical 凭据通过 [`one login`](/zh/docs/login/) 配,不进入 manifest。 ## 交互模式 @@ -106,8 +106,8 @@ one env set JWT_SECRET=dev-only-secret --env dev -p api --yes 读取单个 key: ```bash -one env get DATABASE_URL --env dev -p api -DB_URL=$(one env get DATABASE_URL --env dev -p api -o json | jq -r .value) +one env get DATABASE_URL --env dev -p api --reveal +DB_URL=$(one env get DATABASE_URL --env dev -p api -o json | jq -r .value) --reveal ``` 输出 schema:`one-cli/env-get/v1` @@ -186,8 +186,8 @@ Workspace 级 env 后端写在 `one.manifest.json#domains.env`,环境列表写 "domains": { "env": { "kind": "infisical", - "profile": "work", "config": { + "siteUrl": "https://app.infisical.com", "projectId": "...", "projectName": "my-workspace", "rootPath": "/" @@ -216,18 +216,18 @@ Workspace 级 env 后端写在 `one.manifest.json#domains.env`,环境列表写 } ``` -值本身和本机 Profile 名永远不进 Manifest;Manifest 只记录 Backend、folder path 和 key 名,机器 Profile 定义与环境感知绑定位于 `~/.config/one/`。 +变量值不进入 Manifest;Manifest 记录项目 ID、实例地址、目录和 key 名。认证使用系统钥匙串中的单一浏览器会话。 ## 凭据安全 -`one configure add env/infisical` 写 `~/.config/one/config.json` 与 `~/.config/one/credentials.json`(mode 0600)。不要把 client id / client secret 写进仓库;CI 用 secret store 注入。 +通过 `one login` 登录,令牌只保存在系统钥匙串,不落入项目或普通配置文件。 ## 错误恢复 | 错误码 | 处理 | |---|---| -| `INFISICAL_NOT_CONFIGURED` | 确认工作区用了 `--env-provider infisical`,并有 default `env/infisical` profile | -| `INFISICAL_AUTH_MISSING` | 重新跑 `one configure add env/infisical --profile work ... --use` | +| `INFISICAL_NOT_CONFIGURED` | 确认工作区用了 `--env-provider infisical`,并已通过 `one login` 登录 | +| `INFISICAL_AUTH_MISSING` | 重新跑 `one login` | | `INFISICAL_AUTH_FAILED` | Infisical 后台重新生成 client secret | | `INFISICAL_PROJECT_NAME_TAKEN` | 修改 `domains.env.config.projectName` 后重跑 env 命令触发 lazy bind | | `INFISICAL_PROJECT_CREATE_FORBIDDEN` | 给 machine identity 加 admin 角色,或手动建项目后填 `domains.env.config.projectId` | @@ -243,3 +243,8 @@ Workspace 级 env 后端写在 `one.manifest.json#domains.env`,环境列表写 - [环境变量指南](/zh/tutorials/env-vars/) — 心智模型 + 完整工作流 - [`one create`](/zh/docs/create/) — 起骨架时用 `--env-provider infisical` 接 Infisical + + +## 共享凭据 + +共享凭据独立于工作区。使用 `one env bind --global` 选择存放位置,`one env list --global --env dev --path /` 浏览元数据,`one run --global --env dev --path /folder -- command` 注入明确范围的变量。完整的命令和安全边界见[登录与共享凭据](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index 9700ab0a..6f3f9857 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -169,15 +169,6 @@ Registry is empty. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 -### `REGISTRY_CREDENTIAL_MISSING` - -Container push needs a registry, but none is configured. - -**Remediation**: - -- `build-local` — 只需要本地镜像时,使用 build,不需要 push
运行:`one container build ` -- `setup-registry` — 需要推送到镜像仓库时,先配置 registry
运行:`one configure add container/docker --profile --use` - ### `REGISTRY_FETCH_FAILED` Failed to download the template registry. @@ -232,9 +223,9 @@ Workspace 后置同步失败:写入 manifest 后某个后端 sync 回滚或失 - `retry` — 重试触发该错误的命令 -## 插件 / Profile / 部署 +## Profile / CI / 本地开发 -插件选择、profile 解析、部署 / CI 产物生成过程中的问题。 +Profile 解析、CI 产物生成和本地开发过程中的问题。 ### `CI_DISABLE_CONFIRMATION_REQUIRED` @@ -260,93 +251,12 @@ The selected CI provider returned an error while rendering the workflow. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 -### `IMAGE_REF_INCOMPLETE` - -Deploy / CI backend needs the container image ref but it is missing or incomplete (registry / name / tag). - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `K8S_PACKAGE_UNSUPPORTED` - -A deploy backend selected a Kubernetes packaging form this build does not bundle. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `K8S_PLATFORM_UNDETECTED` - -Kubernetes node architecture could not be detected before building an image for deploy. - -**Remediation**: - -- `check-k8s` — 确认 kubeconfig/context 可访问并能列出节点
运行:`kubectl get nodes -o wide` - ### `LOCAL_ORCH_PORT_CONFLICT` Two projects requested the same dev port and the dev runner could not auto-allocate a free one. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 -### `PROFILE_ALREADY_EXISTS` - -A profile with this name already exists. Re-run `one configure add / --profile ` to update existing credentials, or pick a different name. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `PROFILE_BACKEND_INVALID` - -Profile.backend value is not recognised, or it doesn't belong to the declared domain. - -> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 - -### `PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED` - -Profile's credentialSource is set to a value this build does not implement (only `file` is wired up so far). - -**Remediation**: - -- `use-file-source` — 把 config.json 中该 profile 的 credentialSource 改回 "file"(或删除该字段),并确保对应密钥写在 credentials.json - -### `PROFILE_FILE_INVALID` - -One of config.json, credentials.json, or profile-bindings.json failed to parse as JSON. - -**Remediation**: - -- `edit-profile-file` — 根据 error.context.path 检查并修复对应的机器本地文件;删除 profile-bindings.json 只会清除本机选择,不会删除凭据或修改仓库 - -### `PROFILE_IN_USE` - -The Profile is still selected by one or more environment-aware Workspace or Project bindings. - -**Remediation**: - -- `unbind-profile` — 先在 Dashboard 中把对应 Workspace / Project Profile 选择改为 Automatic,再删除 - -### `PROFILE_NONE_CONFIGURED` - -No Profile resolved from --profile, environment-aware Project/Workspace bindings, legacy bindings, or the machine default. - -**Remediation**: - -- `add-profile` — 创建第一个 profile(替换 / 为对应 pair,如 env/infisical / deploy/aws-s3 / container/docker)
运行:`one configure add / --profile work` - -### `PROFILE_NOT_FOUND` - -Requested profile does not exist under the (domain/backend) section. - -**Remediation**: - -- `list-profiles` —
运行:`one configure list env/infisical` -- `add-profile` — 创建新 profile
运行:`one configure add env/infisical --profile ` - -### `PROFILE_VERSION_UNSUPPORTED` - -A machine-local Profile file schema does not match this binary. - -**Remediation**: - -- `upgrade-cli` — 升级 one cli,或仅重建 error.context.path 指向的不兼容机器本地文件;无需升级 one.manifest.json - ### `RELEASE_FLOW_MISMATCH` The release-flow backend's expected toolchain or repo state does not match the workspace. @@ -460,20 +370,19 @@ Infisical API returned an unexpected error. See error.context for details. ### `INFISICAL_AUTH_FAILED` -Universal Auth login was rejected by Infisical (bad client id / secret, or rate limited). +The Infisical session was rejected or expired. **Remediation**: -- `rotate-credentials` — 重新生成 client secret 或确认 client id 来自正确的 organization +- `login` —
运行:`one login` ### `INFISICAL_AUTH_MISSING` -No default env profile supplies Universal Auth credentials. +No active Infisical browser session. **Remediation**: -- `add-profile` — 在 Infisical → Organization → Access Control → Identities 创建 Universal Auth machine identity,再用 client-id / client-secret 配 profile
运行:`one configure add env/infisical --profile --client-id --client-secret --use` -- `use-existing-profile` — 或切到已配置的 profile
运行:`one configure use env/infisical --profile ` +- `login` —
运行:`one login` ### `INFISICAL_FOLDER_NOT_FOUND` @@ -493,21 +402,17 @@ Network error reaching the Infisical API. Check siteUrl + connectivity. ### `INFISICAL_NOT_CONFIGURED` -one.manifest.json#domains.env is missing, or the workspace is not using env/infisical. +The workspace has no Infisical project binding. **Remediation**: -- `create-with-infisical` — 新工作区在 create 时选择 Infisical
运行:`one create --env-provider infisical` -- `configure-profile` — 已有工作区需确认 manifest.domains.env.kind=infisical,并配置 env/infisical profile
运行:`one configure add env/infisical --profile --use` +- `select-project` — 在 Dashboard 工作区设置中选择 Infisical 项目
运行:`one serve` ### `INFISICAL_PROJECT_CREATE_FORBIDDEN` -机器身份没有 create-project 权限。 - -**Remediation**: +当前账号没有创建项目权限,请选择一个已有且有权访问的项目。 -- `grant-admin-role` — 在 Infisical 后台给该 machine identity 授予 organization-level 的 admin 角色,或先手动建项目并把 projectId 写入 manifest -- `use-explicit-id` — 手动在 UI 创建项目后,把 ID 写进 one.manifest.json#domains.env.config.projectId +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 ### `INFISICAL_PROJECT_NAME_TAKEN` @@ -519,7 +424,7 @@ Infisical 项目名已被占用;auto-bind 会自动加随机后缀重试,但 ### `INFISICAL_PROJECT_NOT_FOUND` -Infisical project id does not exist or the machine identity has no access to it. +Infisical project id does not exist or the current account has no access to it. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 @@ -561,48 +466,6 @@ The active backend in this domain does not implement the requested verb (e.g. `o - `switch-backend` — 切换到支持该 verb 的同 domain backend(例如 env 域改用 infisical) -### `CLOUDFLARE_CLI_MISSING` - -deploy/cloudflare 找不到 wrangler CLI。 - -**Remediation**: - -- `install-project-wrangler` — 在当前 subproject 目录安装 wrangler
运行:`pnpm add -D wrangler` -- `install-wrangler` — 或全局安装 wrangler CLI
运行:`npm i -g wrangler` - -### `CLOUDFLARE_DEPLOY_FAILED` - -wrangler CLI 退出码非 0;查看上游日志获取详情。 - -**Remediation**: - -- `verify-token` — 确认 API token 仍然有效,且对目标 account / Worker 有 Edit Workers 权限 -- `verify-account-id` — 多账号场景下 wrangler 需要 CLOUDFLARE_ACCOUNT_ID;在 profile 里设置 --account-id 或在 dash 里复制 Account ID - -### `CLOUDFLARE_PROFILE_INVALID` - -deploy/cloudflare profile 缺少 API token。 - -**Remediation**: - -- `configure-cloudflare` — 在 dash.cloudflare.com → My Profile → API Tokens 创建 API token,然后写入 profile
运行:`one configure add deploy/cloudflare --profile --use --token $CLOUDFLARE_API_TOKEN` - -### `CONTAINER_KIND_UNKNOWN` - -manifest declares an unrecognised container kind. Supported kinds: docker / dockerhub / ghcr / acr. - -**Remediation**: - -- `fix-manifest-kind` — 把 projects[i].domains.container.kind 改成支持的 kind - -### `CONTAINER_PROFILE_INVALID` - -Container profile is missing required fields for its kind (e.g. acr needs region, docker needs registry). - -**Remediation**: - -- `reconfigure-container` — 重新配置 container profile
运行:`one configure add container/ --profile --use` - ### `DEPENDENCIES_NOT_INSTALLED` Node dependencies required for local development are not installed. @@ -635,56 +498,12 @@ A domain (container / deploy / dev / ci / env) is required but its section is mi > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 -### `EDGEONE_CLI_MISSING` - -deploy/edgeone 找不到 edgeone CLI。 - -**Remediation**: - -- `install-edgeone` — 全局安装腾讯云 EdgeOne CLI
运行:`npm i -g edgeone` -- `install-edgeone-via-pnpm` — 或使用 pnpm 全局安装
运行:`pnpm add -g edgeone` - -### `EDGEONE_DEPLOY_FAILED` - -edgeone CLI 退出码非 0;查看上游日志获取详情。 - -**Remediation**: - -- `verify-token` — 确认 EdgeOne API token 仍然有效,且对目标 EdgeOne Pages 项目有部署权限 -- `verify-project` — 首次部署需要先在 EdgeOne 控制台创建 Pages 项目;project name 写在 manifest.projects[i].domains.deploy.config.projectName - -### `EDGEONE_PROFILE_INVALID` - -deploy/edgeone profile 缺少 EdgeOne API token。 - -**Remediation**: - -- `configure-edgeone` — 创建 EdgeOne Pages API token 后写入 profile
运行:`one configure add deploy/edgeone --profile --use --token $EDGEONE_API_TOKEN` - ### `HOOKS_CONFIG_CONFLICT` Existing Git hooks or hk configuration conflict with One's generated setup. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 -### `IMAGE_TAG_NOT_FOUND` - -Container push target image tag does not exist in the local Docker daemon. - -**Remediation**: - -- `build-image` — 先构建要推送的镜像
运行:`one container build ` - -### `IMAGE_TAG_REQUIRED` - -Container build needs a version tag but no subproject buildVersion, Git tag, or package version was available. - -**Remediation**: - -- `provide-tag` — 显式指定镜像版本 tag
运行:`one container build --build-version v0.1.0` -- `set-build-version` — 或在 one.manifest.json 里设置 projects[].buildVersion -- `create-git-tag` — 或在当前提交上创建 Git tag
运行:`git tag v0.1.0` - ### `MISE_CONFIG_CONFLICT` A managed mise configuration was modified or changed during generation. @@ -715,6 +534,18 @@ Two configuration fragments contributed conflicting patches to the same backend > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 +### `PREFERENCES_FILE_INVALID` + +The local preferences file could not be read or parsed. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + +### `PREFERENCES_INVALID` + +The requested preference value is not supported. + +> 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 + ### `PRESET_FLAG_CONFLICT` Preset id and explicit flag declared conflicting values for the same field. @@ -772,7 +603,7 @@ one run arguments do not match `one run [project] -- [args...]`. ### `SERVE_BIND_FORBIDDEN` -one serve 拒绝绑定到非 loopback 地址(profile 文件含敏感凭据,仅 127.0.0.1 / localhost 才安全)。 +one serve 拒绝绑定到非 loopback 地址(本地接口可操作敏感凭据,仅 127.0.0.1 / localhost 才安全)。 **Remediation**: @@ -815,32 +646,6 @@ Dashboard only writes explicitly allowlisted Project fields and env Backend swit - `list-projects` — 查看现有项目
运行:`cat one.manifest.json` -### `VERCEL_CLI_MISSING` - -deploy/vercel 找不到 vercel CLI。 - -**Remediation**: - -- `install-vercel-cli` — 全局安装 vercel CLI(推荐用 pnpm/npm 全局)
运行:`npm i -g vercel` -- `install-vercel-cli-via-pnpm` — 或使用 pnpm 全局安装
运行:`pnpm add -g vercel` - -### `VERCEL_DEPLOY_FAILED` - -vercel CLI 退出码非 0;查看上游日志获取详情。 - -**Remediation**: - -- `verify-token` — 确认 API token 仍然有效,且对目标 team / project 有 deploy 权限 -- `verify-project-link` — 首次部署需要 vercel link:cd 到项目目录手动跑一次 `vercel link --token $TOKEN` - -### `VERCEL_PROFILE_INVALID` - -deploy/vercel profile 缺少 API token。 - -**Remediation**: - -- `configure-vercel` — 在 vercel.com → Account Settings → Tokens 创建 API token,然后写入 profile
运行:`one configure add deploy/vercel --profile --use --token $VERCEL_TOKEN` - ### `WORKSPACE_NESTED_FORBIDDEN` Refusing to create a workspace inside an existing workspace; nesting one workspace inside another corrupts both manifests. diff --git a/apps/docs/content/docs/zh/hk.md b/apps/docs/content/docs/zh/hk.md index 106cf8a9..012c3031 100644 --- a/apps/docs/content/docs/zh/hk.md +++ b/apps/docs/content/docs/zh/hk.md @@ -31,10 +31,10 @@ one mise exec -- pnpm exec oxfmt --write package.json 新工作区自动安装本地 `pre-commit` 和 `commit-msg` 启动器,继续正常使用 `git commit`。克隆已有工作区后,运行一次: ```bash -one configure hooks +one init hooks ``` -Git 启动器记录本次 One 可执行文件的位置,并通过它解析和运行 mise;不依赖终端的 mise 激活状态。移动或更换 One 安装位置后,可以重新执行 `one configure hooks`。 +Git 启动器记录本次 One 可执行文件的位置,并通过它解析和运行 mise;不依赖终端的 mise 激活状态。移动或更换 One 安装位置后,可以重新执行 `one init hooks`。 ## 默认检查 @@ -62,8 +62,8 @@ Git 启动器记录本次 One 可执行文件的位置,并通过它解析和 ## 迁移旧工作区 ```bash -one configure hooks --dry-run -o json -one configure hooks +one init hooks --dry-run -o json +one init hooks one mise exec -- pnpm install ``` diff --git a/apps/docs/content/docs/zh/installation.md b/apps/docs/content/docs/zh/installation.md index ab09fa0c..f905b3ef 100644 --- a/apps/docs/content/docs/zh/installation.md +++ b/apps/docs/content/docs/zh/installation.md @@ -91,7 +91,7 @@ Windows 归档名是 `one-cli_windows_amd64.zip`。 每次需要 runtime 时重新检查:系统 mise 被删除、版本过旧或不可执行时,One 转用托管版本;托管程序缺失或损坏时自动恢复。系统版本重新可用后恢复系统优先。显式设置 `ONE_MISE_BINARY` 的路径或版本有误时直接报错,不自动回退。 -不需要激活 shell。配置信任遵循 mise 自身规则;设置 `MISE_PARANOID=1` 后需先显式审查并信任配置。旧 workspace 在显式启用前继续沿用已有工具,详见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 +不需要激活 shell。配置信任遵循 mise 自身规则;设置 `MISE_PARANOID=1` 后需先显式审查并信任配置。旧 workspace 在显式启用前继续沿用已有工具,详见 [`one init mise`](/zh/docs/login/#mise-工作区工具配置)。 | 托管内容 | 默认目录 | 自定义根目录 | |---|---|---| @@ -126,38 +126,9 @@ one mise exec -- pnpm install `trust` 请在审查对应配置后运行;自定义配置同样遵循 mise 的信任规则。安装依赖的例子应在 workspace 根目录执行。`one mise` 原样转发参数、IO 和退出码,不额外注入 One 项目密钥;需要项目密钥时继续使用 `one run`。`one mise --help` 展示 One 的入口说明,不探测或下载 mise。 -## 配置 Provider 凭据 +## Infisical 登录 -Provider 凭据用顶层 `one configure add / --profile ` 配(一次配全工作区都能用)。当前支持这些 pair: - -| pair | 什么时候用 | -|---|---| -| `env/infisical` | Infisical 机器身份,跨工作区共享 | -| `deploy/aliyun-oss` | 阿里云 OSS,S3 协议对象存储 | -| `deploy/tencent-cos` | 腾讯云 COS,S3 协议对象存储 | -| `deploy/aws-s3` | AWS S3 | -| `deploy/minio` | 自部署 MinIO | -| `deploy/rustfs` | 自部署 RustFS | -| `deploy/r2` | Cloudflare R2 | -| `deploy/kustomize` | Kubernetes kubeconfig + context | -| `deploy/vercel` | Vercel API token | -| `deploy/cloudflare` | Cloudflare API token | -| `deploy/edgeone` | Tencent EdgeOne Pages API token | -| `container/docker` | 通用 Docker registry 登录信息 | -| `container/dockerhub` | Docker Hub 登录信息 | -| `container/ghcr` | GitHub Container Registry 登录信息 | -| `container/acr` | 阿里云 ACR 登录信息 | - -`env/dotenv` 不需要远端凭据;它直接读写项目本地 `.env`。S3 兼容 deploy 后端共用同一组 profile 字段,但 backend ID 是显式拆开的(`deploy/aws-s3`、`deploy/aliyun-oss`、`deploy/r2` 等)。 - -常用配置例子: - -```bash -one configure add env/infisical --profile work # Infisical 凭据 -one configure add deploy/aws-s3 --profile web-prod # AWS S3 endpoint + ak/sk -one configure add deploy/kustomize --profile prod-k8s # kubeconfig context -one configure add container/ghcr --profile ghcr # GHCR username + PAT -``` +运行 `one login` 在浏览器中登录,会话保存在系统钥匙串。参见[登录与共享凭据](/zh/docs/login/)。 ## 环境变量参考 @@ -194,7 +165,7 @@ macOS / Linux: rm ~/.local/bin/one ``` -如需清理本地 profile 凭据和缓存,可删除 `~/.config/one`。 +运行 `one logout` 删除系统钥匙串中的当前会话。 ## 本地编译版(贡献开发用) diff --git a/apps/docs/content/docs/zh/login.md b/apps/docs/content/docs/zh/login.md new file mode 100644 index 00000000..18001cbf --- /dev/null +++ b/apps/docs/content/docs/zh/login.md @@ -0,0 +1,58 @@ +--- +title: 登录与本机设置 +description: 浏览器登录 Infisical、系统钥匙串与共享凭据。 +--- + +## 浏览器登录 + +```bash +one login +one whoami +one logout +one login --site-url https://secrets.example.com +``` + +One 只保留一个 Infisical 账号。登录会打开浏览器,完成后把会话令牌存入系统钥匙串;不再要求 Client ID、Client Secret 或 Profile。更换账号或实例前先退出。系统钥匙串不可用时会报错,不回退到明文文件。会话过期后需显式重新登录;读取变量不会自动打开浏览器。 + +本机只保存共享凭据的位置、界面语言和工作区记录等元数据。旧版凭据文件不再读取,也不会自动删除。 + +## 共享凭据 + +在 Dashboard 的「共享凭据」页点击「初始化默认位置」,即可创建或复用 `shared-credentials` 项目,并将 `dev` 环境的根目录作为默认浏览位置。也可以直接新建其他项目,或选择已有 Secret Manager 项目。已有存放位置会保留。 + +CLI 仍使用 `--global` 访问共享凭据,也可以手动选择存放位置: + +```bash +one env bind --global +one env bind --global --project-id PROJECT_ID --env dev +one env --global +one env list --global --env dev --path / +one env list --global --env dev --path /docker +one run --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script +``` + +列表返回当前层目录、变量名和说明,不返回值。执行时必须显式提供环境与目录;不会递归读取子目录、导入变量或展开跨目录引用。`--keys` 可进一步缩小注入范围。命令可在工作区之外使用,保留当前目录;普通 `one dev`、`one build` 和项目模式不会自动加载共享凭据。 + +明文读取需要 `one env get KEY --global --env dev --path /docker --reveal`。写入可使用交互式密码输入,或 `one env set KEY --global --env dev --path /docker --stdin` 从标准输入读取;覆盖已有值需要 `--yes`。`one env unset KEY --global --env dev --path /docker` 删除远端变量。 + +## Dashboard + +运行 `one serve`。设置页管理登录、等待回调、取消登录、退出和语言;共享凭据页管理存放项目、浏览环境与目录,以及增删改查变量。查看或复制时才读取明文,切换账号、环境、目录或离开页面会清除页面中的明文。远端变量操作即时生效;工作区绑定项目等 Manifest 修改先进入草稿,审阅后一次保存。 + +## 安全边界 + +变量注入和输出遮盖用于减少误泄露,不是同一系统账号下 Agent 的安全隔离。Agent 能执行任意程序时,仍可能读取或传出凭据;说明文字也是不可信数据。请在 Infisical 和云服务中限制账号权限、目录、环境及凭据有效期。One 对已知原始值做尽力输出遮盖,无法覆盖编码、变形或子进程写出的文件。Docker 等工具也可能自行保存登录凭据。 + +## 本机偏好与工作区工具 + +```bash +one locale zh-CN +one locale en-US +one locale auto +one init mise --dry-run +one init mise +one init hooks --dry-run +one init hooks +``` + +`one init mise` 生成工具配置,保留用户配置;`one init hooks` 配置 hk 检查和当前 checkout 的 Git 钩子。`one mise` 与 `one hk` 保持工具透传。语言偏好存入本机 preferences 文件。 diff --git a/apps/docs/content/docs/zh/manifest.md b/apps/docs/content/docs/zh/manifest.md index 51125d33..c442f29c 100644 --- a/apps/docs/content/docs/zh/manifest.md +++ b/apps/docs/content/docs/zh/manifest.md @@ -1,176 +1,81 @@ --- title: one.manifest.json 是什么 -description: 工作区台账文件 — 谁在写、什么时候改、漂移会发生什么。 +description: 工作区的项目登记表、环境变量来源和本地开发配置。 --- -每个 one cli 工作区根目录都会有一个 `one.manifest.json`。这一页讲它是干什么的、谁该改它、什么时候不该改。 +每个 One CLI 工作区根目录都有 `one.manifest.json`。它记录工作区身份、项目路径、工具链、环境和环境变量来源,供命令定位项目并选择执行方式。 -**适合读这页的人**:第一次看到 manifest 文件不知道怎么处理的人;想搞懂工作区状态从哪里读的人。 +## 示例 -**读完会**:理解 manifest 是工作区的**真源**;知道你(vs one cli)什么时候该改它。 - -## 一句话定义 - -`one.manifest.json` 是工作区的**台账**——记录这个工作区有哪些项目、用了什么模板、为每个 domain 选了哪个 backend(env / deploy / container)、有哪些环境。 - -它的存在 ≡ "这是一个 one cli 工作区"(其它命令通过它的存在判断当前目录是不是 one 工作区)。 - -## 它存了什么 - -下面用 `jsonc` 展示字段含义。真实的 `one.manifest.json` 仍然是标准 JSON,不能把 `//` 注释写进文件里。 - -```jsonc +```json { - "version": 1, // One CLI 写入和读取的 manifest schema 版本 - "workspace": { // 工作区身份信息 - "id": "demo-app-2bb61e", // 工作区唯一 ID,创建时生成 - "name": "demo-app" // 工作区名,通常来自 one create 的目录名或 --name - }, - "environments": { // 工作区支持的环境集合 - "names": ["dev", "preview", "prod"], // 新 Workspace 的默认环境名 - "default": "dev" // 不显式传 --env 时使用的默认环境 - }, - "domains": { // 工作区级 domain 默认配置 - "env": { // secrets / 环境变量后端 - "kind": "infisical", // env 后端:dotenv 或 infisical - "config": { // 当前 env 后端自己的配置 - "keys": ["VITE_API_URL", "VITE_PUBLIC_SITE"], // 已声明的工作区级变量名;值不进 manifest - "projectId": "86c73b57-5d1b-4f99-90dc-5d0c8ee0e823", // Infisical project id - "projectName": "demo-app", // Infisical 里的项目名 - "rootPath": "/" // Infisical 里的根路径 - } - }, - "deploy": { // 工作区级 deploy 默认配置 - "kind": "kustomize", // 默认 deploy 后端 - "config": { // kustomize 后端配置 - "namespace": "demo-app-2bb61e", // 可选 Kubernetes namespace;不写时使用 workspace.id - "kustomizationPath": "kustomize/overlays/prod" // one deploy render/apply 读取的 Kustomize overlay 目录 - } - } + "version": 1, + "workspace": { "id": "demo-app-2bb61e", "name": "demo-app" }, + "environments": { + "names": ["dev", "preview", "prod"], + "default": "dev" }, - "projects": [ // 工作区内的项目登记表 + "domains": { "env": { "kind": "dotenv" } }, + "projects": [ { - "name": "web", // 项目名;one env / one deploy -p 会用它定位项目 - "templateId": "nextjs-app", // 创建这个项目时使用的模板 ID - "relativeDir": "apps/web", // 项目相对工作区根目录的位置 - "toolchain": "node", // 项目工具链:node / go 等 - "buildVersion": "0.1.0", // 默认构建版本;container / deploy 会读取 - "packageManager": "pnpm", // Node 项目使用的包管理器 - "domains": { // 项目级 domain 覆盖 - "container": {}, // 空对象表示启用容器构建并继承 profile 默认值 - "deploy": { "kind": "kustomize" }, // 这个项目使用 kustomize 部署 - "dev": { "command": "pnpm run dev" } // one dev 启动这个项目时执行的命令 - } - }, - { - "name": "spa", // 第二个项目:静态前端应用 - "templateId": "react-spa", // React SPA 模板 - "relativeDir": "apps/spa", // 前端项目目录 - "toolchain": "node", // Node 工具链 - "buildVersion": "0.1.0", // 当前默认构建版本 - "packageManager": "pnpm", // 使用 pnpm install 安装依赖 - "domains": { // 只覆盖这个项目需要不同于 workspace 默认值的部分 - "deploy": { - "kind": "rustfs", // 这个项目走对象存储部署,而不是 workspace 默认 kustomize - "config": { "bucket": "demo-app-2bb61e" } // 对象存储 bucket;未写时通常使用 workspace.id - }, - "dev": { "command": "pnpm run dev" } // one dev 启动命令 + "name": "web", + "templateId": "react-spa", + "relativeDir": "apps/web", + "toolchain": "node", + "buildVersion": "0.1.0", + "packageManager": "pnpm", + "domains": { + "env": { "path": ".env", "inherits": true, "keys": ["API_URL"] }, + "dev": { "command": "pnpm dev" } } }, { - "name": "api", // 后端服务 - "templateId": "go-api", // Go API 模板 - "relativeDir": "services/api", // Go 服务目录 - "toolchain": "go", // Go 工具链 - "buildVersion": "0.1.0", // 当前默认构建版本 - "domains": { - "container": {}, // 启用容器构建 - "deploy": { "kind": "kustomize" }, // 这个服务部署到 Kubernetes - "dev": { "command": "go run ./cmd/server" } // Go 项目的 one dev 启动命令 - } + "name": "api", + "templateId": "go-api", + "relativeDir": "services/api", + "toolchain": "go", + "domains": { "dev": { "command": "go run ./cmd/server" } } } ] } ``` -主要字段: +实际文件使用严格 JSON,不接受注释和未知字段。 + +## 主要字段 | 字段 | 含义 | |---|---| -| `version` | One CLI 写入和读取的 manifest schema 版本 | -| `workspace` | 工作区身份(`id` + `name`)。`one create --env-provider infisical` 的自动绑定会用 `name` 命名 Infisical 项目 | -| `environments` | 环境名列表 + 默认环境。被 secrets backend、`one deploy --env`、每个 project 的 `domains.deploy.config.env` 三处共用 | -| `domains.env` / `.deploy` / `.container` | 工作区级 backend 选择:`{kind, config}`,按需出现。`kind` 是 bare backend 名(`dotenv` / `infisical` / `kustomize` / ...),`config` 是 kind-specific JSON blob | -| `projects[]` | **核心**——所有项目登记表;每项带自己的 `buildVersion`(默认 `0.1.0`)和可选的 `domains` override block | -| `projects[].domains.env` | 项目级 env override(path / inherits / disabled / keys),无 `kind`(继承 workspace) | -| `projects[].domains.container` | 项目级 container override(kind / image / namespace)。空对象表示这个项目启用容器构建,并使用本机 profile 解析链 | -| `projects[].domains.deploy` | 项目级 deploy backend,**有** `kind`(deploy 是真正按项目变种的:web → vercel、api → kustomize) | -| `projects[].domains.dev` | 项目级开发启动命令。`one dev` 读取 `command`,例如 Node 项目 `pnpm run dev`、Go 项目 `go run ./cmd/server` | - -`domains.deploy.config.namespace` 是可选覆盖;不写时,Kubernetes namespace 默认使用 `workspace.id`(例如 `demo-app-2bb61e`)。只有你想把多个 workspace 放进同一个固定 namespace,或者想用 `demo-app-prod` 这类环境命名时,才需要显式写它。 +| `version` | Manifest 版本,当前为 `1` | +| `workspace` | 稳定的工作区 `id` 与名称 `name` | +| `environments` | 环境名称和默认环境 | +| `domains.env` | 工作区环境变量来源:`dotenv` 或 `infisical`,可附带后端专属 `config` | +| `projects[]` | 项目名称、路径、模板、工具链,可选的 `packageManager` 和 `buildVersion` | +| `projects[].domains.env` | 项目覆盖项:`path`、`inherits`、`disabled` 和变量名 `keys`;后端继承工作区 | +| `projects[].domains.dev` | `one dev` 执行的 `command` | -> 设计要点:workspace 级和项目级都用 `domains` 包起来,词汇一致。env 在项目级只放 override;deploy 在项目级带 `kind`,因为不同项目可以走不同部署后端;container 项目级可以是空对象,也可以带 image / profile / namespace / kind 等覆盖项。 +Infisical 的 `domains.env.config` 可以包含 `projectId`、`projectName`、`rootPath` 和 `keys`。Manifest 不保存变量值或本机 Profile 名;凭据保存在本机 Profile,变量值交给 dotenv 或 Infisical。 -## 谁在写它 +## 谁会修改它 -| 命令 | 改 manifest | +| 操作 | 修改内容 | |---|---| -| `one create` | 创建初始 manifest,含 `workspace` 身份 + 空 `projects` 数组;同时写入 `domains.env.kind`、`environments`,本地开发可用但不配置 CI | -| `one add` | 给 `projects[]` 加一项,写入 `projects[].domains.dev.command`,同步本地开发;CI 与部署保持未配置 | -| 首次 `one deploy ` | 本机连接准备好后写入所选兼容部署目标及其生成产物 | -| `one env set` | 把变量名记到 `domains.env.config.keys` 或 `projects[i].domains.env.keys`(值不进 manifest);Infisical 未绑定时会触发 lazy auto-bind | -| `one container build` | 写回 `projects[i].domains.container.image`,并按需写 `domains.container.config.platform` | -| `one deploy --env ` | **不写** manifest;只把 `--env` 透传给当前 deploy 调用 | -| **你**(手工) | 极少;下面讲 | - -## 什么时候你该手工改 - -90% 的情况你不需要碰它。剩下的少数场景: - - -1. **重命名项目**——目前没有 `one rename` 命令。手动改 manifest 里的 `name` + 改文件夹名,让 manifest 与磁盘对齐即可 -2. **删除项目**——目前没有 `one remove`。手动从 `projects[]` 里删掉对应条目,删掉它的文件夹 - -3. **切换 deploy 后端**——比如把 `web` 从 `aws-s3` 改到 `vercel`:编辑 `projects[i].domains.deploy.kind`,并用 `one configure use deploy/vercel --profile ` 切 default profile -4. **调整本地开发命令**——比如把 `projects[i].domains.dev.command` 从 `pnpm run dev` 改成项目自己的启动脚本。`one dev` 以 manifest 为准,不会自动追踪后续 `package.json` 变化 - -> 这些字段由 One CLI 维护,不需要手改: -> - `workspace.roots`:永远是 `apps/services/packages`,写死代码 -> - 顶层 `ci` / `dev`:永远启用,不再 opt-in;删掉这两个字段或加上都不会被读。项目级 `projects[].domains.dev.command` 仍然会被 `one dev` 读取 +| `one create` | 创建工作区身份、默认环境、环境来源和空项目列表 | +| `one add` | 登记项目及其开发命令 | +| `one env set` | 登记变量名;Infisical 可初始化项目绑定 | +| `one env switch` | 修改环境变量来源 | +| `one serve` | 用户审阅后,经过 revision 校验保存项目配置或环境来源变更 | -## 漂移会怎样 +`one build` 按工具链选择项目构建命令:Node 项目使用包脚本,Go 项目使用 `Taskfile.yml`。CI 通过 `one ci` 单独管理。 -"漂移" = manifest 里写的和文件系统真实状态不一致。常见场景: +## 手动修改 -- 你手工删了 `services/user-api/` 文件夹,但忘了改 manifest -- 你 git pull 拉到了同事加的项目,但本地 manifest 没刷 -- 模板生成时某些步骤失败,项目登记了但 `ready: false` +重命名或删除项目时,应同时维护登记信息和磁盘目录。项目开发脚本变化后,更新 `projects[].domains.dev.command`。目录约定保持 `apps/`、`services/`、`packages/`。 -可以重新跑相关 per-domain 命令(`one add` / `one container build` / `one deploy render` 等)来重对齐;它们会读 manifest 并报告自己缺什么。 +如果清单与磁盘不一致,检查登记路径,恢复缺少的项目文件或修正登记项。业务变量值、依赖、缓存和构建产物不应写入 Manifest。 -## 不改 manifest 的事 +## 已移除的部署配置 -不要把这些放进 manifest: - -- 业务运行时配置(数据库连接、API URL 等)→ 用 `.env` + Secrets -- 项目自己的依赖 / 脚本 → 在项目自己的 `package.json` 里 -- 用户偏好(编辑器配置)→ 不在工作区级别管 -- 临时状态(构建产物、缓存)→ gitignore - -## 校验 - -`one.manifest.json` 有 schema 校验。格式坏了会冒泡 `MANIFEST_INVALID`;缺失或空会冒泡 `MANIFEST_MISSING_OR_EMPTY`,remediation 指向 `one create` / `one add`。 - -详见 [错误码大全](/zh/docs/error-codes/) 的 `MANIFEST_*` 章节。 - -## 想看实例 - -跑 `one create my-app && cat my-app/one.manifest.json` 就能看到一个新工作区 manifest:里面会有 `workspace` 身份、默认环境集合、env 后端选择和空 `projects` 数组。再加一个项目: - -```bash -cd my-app -one add nestjs-api --name api -cat one.manifest.json -``` +`deploy` 和 `container` 域已下线。清单中仍有这些字段时返回 `MANIFEST_INVALID`,并提示手动删除对应字段。CLI 不提供迁移,也不会清理已有 Dockerfile、平台配置或 CI 文件。 -看 `projects[]` 多了一条并带有 dev 命令;部署 / 镜像字段直到首次部署前都保持为空。 +无效 JSON 或未知字段同样返回 `MANIFEST_INVALID`。详见[错误码](/zh/docs/error-codes/)。 diff --git a/apps/docs/content/docs/zh/meta.json b/apps/docs/content/docs/zh/meta.json index f2e4453e..51e18d53 100644 --- a/apps/docs/content/docs/zh/meta.json +++ b/apps/docs/content/docs/zh/meta.json @@ -11,13 +11,12 @@ "create", "add", "env-vars", - "configure", + "login", "hk", "templates-cmd", - "container", "dev", + "build", "ci", - "deploy", "run", "serve", "error-codes" diff --git a/apps/docs/content/docs/zh/quick-start.md b/apps/docs/content/docs/zh/quick-start.md index c02d91c9..74d8f0e1 100644 --- a/apps/docs/content/docs/zh/quick-start.md +++ b/apps/docs/content/docs/zh/quick-start.md @@ -58,7 +58,7 @@ pnpm -C apps/web dev | `pnpm install` | 下载项目用到的包 | | `pnpm -C apps/web dev` | 启动第一个 Web 项目 | -后面的能力按目标进入对应流程:环境变量用 `one env`,上线走 `one deploy`。容器镜像构建 / 推送属于部署流程里的底层环节,需要单独控制时再看进阶文档。 +后续使用 `one env` 管理环境变量,使用 `one build` 构建项目。 ## 下一步 diff --git a/apps/docs/content/docs/zh/run.md b/apps/docs/content/docs/zh/run.md index 9d56bab3..b82ab412 100644 --- a/apps/docs/content/docs/zh/run.md +++ b/apps/docs/content/docs/zh/run.md @@ -33,7 +33,7 @@ one run [-p ] [--env-provider dotenv|infisical] [--env ] -- --use` | +| `INFISICAL_AUTH_MISSING` | 先 `one login` | 完整码表:[错误码大全](/zh/docs/error-codes/)。 @@ -85,3 +85,13 @@ one run --env staging -- npm run e2e - [环境变量注入命令](/zh/tutorials/run-passthrough/) — 真实使用场景 - [`one env`](/zh/docs/env-vars/) — 设置 / 拉取环境变量 - [`one dev`](/zh/docs/dev/) — 启动全部可开发项目 + + +## 使用全局凭据 + +```bash +one run --global --env dev --path /oss --keys OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET -- upload-assets +one run --global --env dev --path /oss --dry-run -- upload-assets +``` + +环境和目录必须显式指定。只读取该层目录,指定 `--keys` 时只获取所选变量;dry-run 不读取凭据。全局模式不加载项目环境或仓库内的隐式命令路径。命令在当前目录运行;输出遮盖仅尽力匹配原始密钥值,不是安全沙箱。 diff --git a/apps/docs/content/docs/zh/serve.md b/apps/docs/content/docs/zh/serve.md index fb04436f..93642006 100644 --- a/apps/docs/content/docs/zh/serve.md +++ b/apps/docs/content/docs/zh/serve.md @@ -1,196 +1,17 @@ --- title: one serve -description: 本地 Web UI 管理 Workspace、Project 与机器级 Profile。 +description: 本地工作区、登录与共享凭据 Dashboard。 --- -`one serve` 启动一个仅监听 `127.0.0.1` 的本地 Dashboard,并自动打开浏览器。Dashboard 会列出这台机器上已识别的 Workspace,让你切换 Workspace、以草稿方式修改并审阅 Workspace 环境变量 Backend 与 Project 配置、管理 Infisical 密钥,以及管理 `one configure` 使用的机器级 Profile。 - -为什么仍不让 AI 直接编辑 Profile 文件:里面是 API key、kubeconfig path、registry token,泄漏代价高于 AI 能省下的几次输入。`one serve` 是把这些字段从命令行 / agent 上下文里物理隔离出来的入口。 - -## 用法 - -```bash -one serve [options] -``` - -启动后阻塞在前台,按 Ctrl-C 退出。Workspace 环境变量 Backend 与 Project 配置修改先保留为浏览器草稿;右上角保存按钮展示精确差异,用户确认后,Project 修改通过 revision 校验的原子 Manifest patch 发布,Backend 修改通过 revision 校验的 env switch 流程发布。选择 Infisical 会初始化并持久化 Workspace 的 Infisical 项目绑定,但不会在 provider 之间迁移已有密钥值。源码仍保持只读。Profile 编辑与 `one configure` 共用 `~/.config/one/{config,credentials}.json`;Workspace/Project 选择只把 Profile 名写入 `~/.config/one/profile-bindings.json`。 - -## 参数 - -| 参数 | 说明 | -|---|---| -| `--host ` | 绑定主机;只接受 loopback(默认 `127.0.0.1`,也允许 `localhost`、`::1`)。非 loopback 直接报 `SERVE_BIND_FORBIDDEN`,无逃生 | -| `--port ` | 监听端口;默认 `0` = 由内核分配空闲端口,避免冲突 | -| `--open` | 完成后自动用浏览器打开(默认 `true`);CI / headless / WSL / 远程 SSH 场景传 `--open=false` 关闭 | -| `-o, --output ` | `json` / `yaml` / `text`(默认按 TTY 检测) | - -## 交互模式 - -`one serve` 没有终端交互式向导。浏览器可以把 Workspace 环境变量 Backend,以及白名单内的 Project 运行、环境、容器和部署配置加入草稿,再统一确认写入 Manifest。Profile 绑定仍是独立的机器本地保存。Workspace 使用 `env/infisical` 时,还可以列出 key,并逐条新增、显示、修改或删除远端值。 - -本地人工配置直接运行 `one serve`;脚本、CI、agent 可以用 `--open=false` 获取普通的 loopback URL 并直接调用 API。由于 API 能读取和修改敏感配置,不要在存在不可信本地进程的共享机器上运行它。 - -## Workspace 识别与持久化 - -One CLI 在两种情况下把 Workspace 登记到本机列表: - -- `one create` 完整创建成功后; -- 在 Workspace 根目录或任意子目录执行 `one serve` 时。 - -记录保存在 XDG-aware 的 `~/.config/one/workspaces.json`。这里只存本机条目 ID、Manifest Workspace ID、名称、规范化绝对路径和最近访问时间,不复制 Project 配置、Backend、Profile 或凭据。Workspace 目录暂时不可用时会保留并显示为 missing;Forget 只删除本机列表记录,不会删除目录、Manifest、Profile 或凭据。 - -在 Workspace 外运行 `one serve` 也可以打开历史列表。Dashboard 默认选中本次启动所在的 Workspace;没有当前 Workspace 时,选中最近访问且可用的记录。 - -## 环境选择与本机存储 - -Dashboard UI 环境选择器只提供开发(`?env=dev`)、预览(`?env=preview`)和生产(`?env=prod`)三种;UI 中的未知 query 值会回退到开发环境。切换它不会向 Manifest 添加环境,也不会升级 Manifest schema。核心/API 存储仍可表示 `staging` 等由其他 CLI/API 工作流传入的安全自定义 ID。 - -全局 Settings 页会隐藏环境选择器,因为 Profile 定义和 CRUD 是机器全局的,不按环境分区。链接仍保留 query,返回 Workspace/Project 时会恢复之前的绑定上下文。 - -```text -~/.config/one/ -├── config.json # Profile 名、非敏感字段、default、旧绑定 -├── credentials.json # Profile 凭据 -├── profile-bindings.json # v1:规范化 root + environment -> Profile 名 -└── workspaces.json # 已识别 Workspace 注册表 -``` - -`profile-bindings.json` 是 mode `0600` 原子替换的机器本地 v1 存储。它用规范化 Workspace root 作为 key,所以即使两份代码拷贝带着相同 Manifest Workspace ID,选择也互不影响。文件不含凭据值,也不会写入任何代码库。 - -对一个 `(domain, backend)`,Profile 解析顺序为: - -1. 本次命令的 `--profile`; -2. Project + environment 绑定; -3. Workspace + environment 绑定; -4. `config.json` 中的旧 Project 绑定; -5. `config.json` 中的旧 Workspace 绑定; -6. 机器 default。 - -## 输出 - -绑定成功后立即向 stdout 发出一次启动信封,然后阻塞: - -```json -{ - "schema": "one-cli/serve/v2", - "status": "listening", - "url": "http://127.0.0.1:54321/", - "host": "127.0.0.1", - "port": 54321 -} -``` - -启动 URL 不包含登录信息,API 也不使用 session token。进程退出后服务随即停止;如果以后有另一个 `one serve` 复用了相同端口,原 URL 会指向新的本地进程。 - -## 安全模型 - -`one serve` 持有 profile 文件,profile 文件持有凭据,因此这个本地服务属于敏感接口。它只信任本机边界,不做 session 级身份认证;任何能访问该 loopback 端口的本地进程都可以调用 API。以下防御仍然生效: - -| 防御层 | 挡住的威胁 | 行为 | -|---|---|---| -| Host header 校验 | DNS rebinding(攻击者域名 resolve 到 127.0.0.1) | `Host` 必须是绑定的 `127.0.0.1:` 或 `localhost:`,否则返 `421 Misdirected Request` | -| Origin 校验(仅 mutating) | 跨源表单 / 脚本 POST | POST/PUT/DELETE 的 `Origin` 必须等于服务 self-origin,否则 `403 Forbidden` | -| 类型化代码库发布器 | 过期草稿或越权字段覆盖配置 | Project patch 与 env Backend 切换使用各自的白名单接口;revision 不匹配返回 `SERVE_MANIFEST_CONFLICT` | -| 旧路由边界 | 旧客户端调用历史 settings PUT | 旧 mutation 路径返回 `409 SERVE_REPOSITORY_READ_ONLY` | - -凭据**默认掩码**:`GET /api/configure*` 返回 `clientSecret: "********"` / `accessKeySecret: "********"` / `password: "********"`。UI 的 "显示原文" 按钮调 `?reveal=1` 取真值。Infisical 列表只返回 key;原文按单条请求并带 `Cache-Control: no-store`,也不会进入 SWR 缓存。Workspace/Project 投影只返回解析到的 Profile 名和 source,不返回 Profile 字段或凭据。 - -不在范围: - -- 多用户访问(仅 127.0.0.1 单人) -- 0.0.0.0 / 局域网暴露(`SERVE_BIND_FORBIDDEN` 直接拒绝) -- 文件外部变更实时推送(外部 `one configure ... add` 改了文件,需要刷浏览器才能看到) - -## 示例 - -### 默认(推荐):随机端口 + 自动开浏览器 - ```bash one serve -# ✓ profile UI 已启动: http://127.0.0.1:54321/ -# 系统默认浏览器自动打开,Ctrl-C 退出 -``` - -### CI / headless / WSL:只要 URL,不开浏览器 - -```bash -one serve --open=false -# 印出 URL,由你或别的工具自己用 -``` - -### 固定端口(测试 / 文档截屏) - -```bash -one serve --port 17900 +one serve --port 0 --open=false ``` -### 容器 / 远程 SSH - -`one serve` 默认只绑 127.0.0.1。要在远端机器跑、本地浏览器访问,靠 SSH 端口转发: - -```bash -# 远端 -one serve --open=false --port 17900 - -# 本地 -ssh -L 17900:127.0.0.1:17900 remote-host -# 复制远端 stdout 打印的 URL(替换主机为 127.0.0.1)打开 -``` - -不要试图改 `--host 0.0.0.0`——会被 `SERVE_BIND_FORBIDDEN` 直接拒掉。 - -## REST API - -UI 用什么,你就能用什么。所有路由都需要 Host 头匹配;mutating 路由还需要 Origin 头匹配,不需要 token。 - -| 方法 | 路径 | 说明 | 响应 schema | -|---|---|---|---| -| `GET` | `/api/configure` | 全部 profile section | `one-cli/serve-configure-config/v1` | -| `GET` | `/api/configure/{domain}/{backend}` | 单个 section(`?reveal=1` 取真值) | `one-cli/serve-configure-section/v1` | -| `POST` | `/api/configure/{domain}/{backend}` | upsert:body `{name, profile, use?}` | `one-cli/serve-configure-upsert/v1` | -| `DELETE` | `/api/configure/{domain}/{backend}/{name}` | 删除 | `one-cli/serve-configure-remove/v1` | -| `PUT` | `/api/configure/{domain}/{backend}/default` | 切 default:body `{name}` | `one-cli/serve-configure-use/v1` | -| `GET` | `/api/workspaces` | 本机 Workspace 列表与状态 | `one-cli/workspaces/v1` | -| `DELETE` | `/api/workspaces/{entryId}` | Forget 本机记录,不删除 Workspace | 无响应体 | -| `GET` | `/api/workspaces/{entryId}/overview` | 所选 Workspace 与 Project 概览 | `one-cli/workspace-overview/v1` | -| `GET` | `/api/workspaces/{entryId}/profile-bindings/env?env={environment}` | Workspace env Profile 的有效名/source | `one-cli/workspace-profile/v1` | -| `PUT` | `/api/workspaces/{entryId}/profile-bindings/env?env={environment}` | 选择/取消 Workspace env Profile;body `{profile}` | `one-cli/workspace-profile/v1` | -| `PUT` | `/api/workspaces/{entryId}/environment/backend?env={environment}` | 带 revision 校验的 env Backend 切换;body `{revision, backend}` | `one-cli/workspace-profile/v1` | -| `POST` | `/api/workspaces/{entryId}/environment/backend/initialize?env={environment}&project={name?}` | 修复缺失的 Infisical 项目绑定 | `one-cli/workspace-profile/v1` | -| `GET` | `/api/workspaces/{entryId}/projects/{name}?env={environment}` | Project/配置投影、Manifest revision 与有效 Profile 名 | `one-cli/workspace-project/v1` | -| `PUT` | `/api/workspaces/{entryId}/projects/{name}/profile-bindings/{domain}?env={environment}` | 选择/取消 Project Profile;body `{profile}` | `one-cli/workspace-project/v1` | -| `PUT` | `/api/workspaces/{entryId}/manifest` | 应用已审阅的类型化 Project patch;body `{revision, changes}` | `one-cli/workspace-manifest-apply/v1` | -| `GET/POST` | `/api/workspaces/{entryId}/secrets?env={environment}&project={name?}` | 列出直接定义的 key / 新增一条 Infisical 值 | `one-cli/env-list/v1` / `one-cli/env-set/v1` | -| `GET/PUT/DELETE` | `/api/workspaces/{entryId}/secrets/{key}?env={environment}&project={name?}` | 显示、修改或删除单条 Infisical 值 | `one-cli/env-get/v1`、`one-cli/env-set/v1` 或 `one-cli/env-delete/v1` | -| `GET/PUT` | `/api/workspace/profile-bindings/env?env={environment}` | 启动 Workspace 的 Workspace 绑定别名 | 与复数路由相同 | -| `PUT` | `/api/workspace/environment/backend?env={environment}` | 启动 Workspace 的 Backend 切换别名 | `one-cli/workspace-profile/v1` | -| `POST` | `/api/workspace/environment/backend/initialize?env={environment}&project={name?}` | 启动 Workspace 的绑定修复别名 | `one-cli/workspace-profile/v1` | -| `GET` | `/api/workspace/projects/{name}?env={environment}` | 启动 Workspace 的 Project 投影别名 | `one-cli/workspace-project/v1` | -| `PUT` | `/api/workspace/projects/{name}/profile-bindings/{domain}?env={environment}` | 启动 Workspace 的 Project 绑定别名;body `{profile}` | `one-cli/workspace-project/v1` | - -复数 Workspace API 只接受不透明的 `entryId`。服务端从注册表解析路径,并在每次读取或 mutation 前重新校验 Manifest;客户端提交的任意 `root` 不会参与路径选择。Manifest 发布接收类型化 patch,而不是整份替换文档。密钥 folder 由服务端根据 Workspace/Project 推导,浏览器不能提交任意 path。空 Profile 字符串会删除该层直接绑定,恢复 fallback 解析。 - -旧的 Project/Environment/Deploy/Container settings PUT 路径(包括 `/api/workspace/...` 和 `/api/workspaces/{entryId}/...`)仍为旧客户端保留,但始终返回 `409 SERVE_REPOSITORY_READ_ONLY`;代码库写入使用带 revision 校验的 `/manifest` 与 `/environment/backend` 路由。复制 Workspace 导致两个有效路径共用一个 Manifest ID 时,两条记录都会保留并标记冲突:允许只读检查,所有 mutation 在冲突解决前返回 `409 Conflict`。 - -合法 `(domain, backend)` 包括:`env/infisical`、`env/dotenv`、`deploy/aws-s3`、`deploy/aliyun-oss`、`deploy/tencent-cos`、`deploy/minio`、`deploy/rustfs`、`deploy/r2`、`deploy/kustomize`、`deploy/vercel`、`deploy/cloudflare`、`deploy/edgeone`、`container/docker`。其它组合返回 404。 - -curl 探活示例(替换 `` 为 stdout 信封里的端口): - -```bash -curl -s "http://127.0.0.1:/api/configure" | jq '.config | keys' -``` +服务只允许回环地址。侧栏提供工作区、共享凭据和设置;所有页面共用当前 Infisical 账号。设置页打开浏览器登录,支持等待、重新打开、取消、退出和自定义实例。 -## 错误恢复 +共享凭据页支持一键初始化默认位置、新建项目或选择已有 Secret Manager 项目作为存放位置,按环境与目录浏览名称和说明。列表不包含明文;查看、复制、编辑和删除均显式操作。远端修改立即生效,删除会显示完整目标范围。 -| 错误码 | 处理 | -|---|---| -| `SERVE_PORT_BUSY` | 换端口,或 `--port 0` 让内核挑空闲端口 | -| `SERVE_BIND_FORBIDDEN` | 仅允许绑定 loopback;改回 `127.0.0.1`(远程访问走 SSH 隧道) | -| `SERVE_PAYLOAD_INVALID` | POST/PUT 请求体不是合法 JSON 或缺必填字段(如 `name` 或 `profile`) | -| `SERVE_MANIFEST_CONFLICT` | 重新加载 Workspace,检查当前 Manifest 后再创建草稿 | -| `SERVE_REPOSITORY_READ_ONLY` | 使用类型化 `/manifest` 草稿流程;当前调用的旧路由不可写 | -| `PROFILE_FILE_INVALID` | 修复错误指向的本机 Profile 文件(`config.json`、`credentials.json` 或 `profile-bindings.json`) | -| `PROFILE_IN_USE` | 先把所有引用该 Profile 的 Workspace/Project 环境绑定改为 **Automatic**,再删除 | -| `PROFILE_BACKEND_INVALID` | URL 里的 `(domain, backend)` 不是合法 pair | +工作区概览展示项目构建命令和来源。工作区环境选择已有 Infisical 项目,项目配置与工作区绑定通过统一 Manifest 草稿审阅和保存;远端变量不进入草稿或仓库。浏览环境不会改变默认环境。 -完整码表:[错误码大全](/zh/docs/error-codes/)。 +安全限制及 CLI 用法见[登录与本机设置](/zh/docs/login/)。 diff --git a/apps/docs/content/docs/zh/templates.md b/apps/docs/content/docs/zh/templates.md index 8163fd86..296fe281 100644 --- a/apps/docs/content/docs/zh/templates.md +++ b/apps/docs/content/docs/zh/templates.md @@ -97,3 +97,18 @@ one add ts-library --name shared 跑 `one templates -o json` 看每个模板的完整描述,或者直接 `one add` 进入交互式选择 —— 选择器里会带上类别和一句话提示。 或者直接选 [推荐组合](#推荐组合) 里的栈,先跑起来,跑不通再换。 + +## 模板依赖和 Electron 工作区 + +Node 模板不复制预生成的锁文件。`one dev` 会按需生成或更新仓库根锁文件, +请将它提交 Git;`one build` 对已有锁文件执行严格校验。 + +`electron-app` 需要 pnpm 工作区。它仍是一个 One 项目,内部的主进程、UI 和 preload +包会自动加入根 `pnpm-workspace.yaml`,共享根锁文件。包名以项目名作为 scope, +例如 `@desktop/electron`、`@desktop/ui`、`@desktop/preload`,可在同一仓库中添加多个桌面应用。 + +模板沿用根目录包管理器版本、registry 和镜像配置。已有安装脚本策略会保留;未配置时 +使用内置模板默认规则。若显式禁用了 Electron 安装脚本,需要在根目录调整策略。 +多个桌面应用同时开发时,通过各项目环境中的 `ELECTRON_RENDERER_PORT` 配置不同端口。 + +这些规则适用于新生成的项目,已有 Electron 项目不会自动改写目录或依赖配置。 diff --git a/apps/docs/content/tutorials/en/configure-profiles.mdx b/apps/docs/content/tutorials/en/configure-profiles.mdx deleted file mode 100644 index 4bf71477..00000000 --- a/apps/docs/content/tutorials/en/configure-profiles.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: Manage multi-platform secrets -description: Centrally manage credentials for 12 platforms with one configure — what each pair stores, where the files live, and how runtime picks them. ---- - -`one configure` manages machine-local **profiles** — credential sets for the endpoints One CLI talks to. One workspace can use many profiles (one for staging cluster, one for prod); one profile can be used from many workspaces. - -The [basics env vars](/en/tutorials/env-vars/) and [basics deploy](/en/tutorials/deploy/) tutorials each used a single profile to show the happy path. This page is the full reference for managing them. - -## The 12 (domain, backend) pairs - -| Pair | What the profile contains | -|---|---| -| `env/infisical` | Universal Auth machine identity — `siteUrl`, `clientId`, `clientSecret` | -| `deploy/aws-s3` | `region`, `accessKeyId`, `accessKeySecret`, optional `endpoint` | -| `deploy/aliyun-oss` | `endpoint`, `region`, AK/SK | -| `deploy/tencent-cos` | `endpoint`, `region`, AK/SK | -| `deploy/minio` | `endpoint`, AK/SK (path-style by default) | -| `deploy/rustfs` | Same shape as minio | -| `deploy/r2` | Cloudflare R2: `endpoint`, AK/SK (`region` is `auto`) | -| `deploy/kustomize` | `kubeconfigPath`, `context`, `namespace` | -| `deploy/vercel` | `token`, optional `teamSlug` | -| `deploy/cloudflare` | `accountId`, `apiToken` | -| `deploy/edgeone` | EdgeOne secret + project info | -| `container/docker` | `registry`, `namespace`, `username`, `password` (a token works) | - -Run `one configure add --help` for the live list. Each pair has its own sub-subcommand with backend-specific flags; run `one configure add --help` for that backend's flags. - -## Storage: two files, mode 0600 - -``` -~/.config/one/ -├── config.json # endpoint / region / default pointer (non-sensitive) -└── credentials.json # clientSecret / accessKeySecret / passwords (sensitive) -``` - -Both files are mode `0600` — owner-readable only. Neither goes in git. **Do not** copy them to a shared dotfile repo. - -The split mirrors AWS CLI's two-file model: anything sensitive lives in `credentials.json`, anything that's safe to read in a screenshot or commit somewhere else lives in `config.json`. - -## Add or update a profile - -```bash -# Interactive (recommended on a new machine): -one configure -# → asks for (domain, backend) → walks the matching flow - -# Or jump straight to one pair: -one configure add deploy/aws-s3 --profile prod \ - --region us-east-1 \ - --access-key-id \ - --access-key-secret \ - --use -``` - -Rules: - -- First time for a given `(pair, profile)`: status = `completed`, automatically becomes default. -- Same `(pair, profile)` again: status = `updated` (overwrites credentials). -- `--use` makes this profile the default afterwards. - -## List, switch, inspect - -```bash -# List every profile across every pair: -one configure list - -# Only a single pair: -one configure list deploy/aws-s3 - -# Print default profiles: -one configure current -one configure current deploy/aws-s3 - -# Print one profile's full contents (credentials masked by default): -one configure show deploy/aws-s3 --profile prod -one configure show deploy/aws-s3 --profile prod --reveal # unmask - -# Switch default: -one configure use deploy/aws-s3 --profile staging - -# Delete: -one configure remove deploy/aws-s3 --profile old -``` - -`-o json` works on all of these — useful for scripting profile rotation. - -## Profile resolution chain - -When you run a command that needs a profile (e.g. `one deploy`, `one env pull`, `one container push`), One CLI picks one in this order: - -1. **`--profile` flag** on the current command (highest priority; one-shot use). -2. **Environment-aware Project binding** — a Profile name in `profile-bindings.json` for canonical root + environment + Project. -3. **Environment-aware Workspace binding** — the Workspace-level name for that canonical root + environment. -4. **Legacy local Project binding** — `config.json#workspaces[workspaceId].projects[project].profiles[...]`. -5. **Legacy local Workspace binding** — set with `one configure use --profile --workspace`. -6. **Machine default pointer** — whatever `one configure use --profile ` last set (or the first profile, if none was explicitly chosen). - -Manifest files do not store local Profile names. The Dashboard writes levels 2–3 as names only; it never edits repository files. `one configure current ` reports the default pointer (level 6). To debug what a specific command will pick, dry-run with `-o json`: - -```bash -one deploy -p api --env staging -o json --dry-run | jq '.profile' -``` - -## Locale: switch CLI language - -The 14th `configure` subcommand isn't a `(domain, backend)` pair — it's the CLI's own UI language: - -```bash -one configure locale --set zh-CN -one configure locale --set en-US -``` - -This affects only the human-readable text (`error.message`, prompts, table headers). Error **codes** (`error.code`) and JSON output stay identical across locales — agents should dispatch on `error.code`, not `error.message`. - -## Common errors - -| Code | Symptom | Fix | -|---|---|---| -| `DOMAIN_INVALID` | Pair is malformed (e.g. `env/foo` where `foo` isn't a known backend) | Use one of the 13 supported pairs | -| `BACKEND_ID_UNKNOWN` | Same as above, surfaced from a different code path | Same fix | -| `PROFILE_NOT_FOUND` | A command (or `--profile` flag) referenced a profile name that's not in `config.json` for that pair | `one configure list ` to see what exists | -| `PROFILE_NAME_REQUIRED` | Subcommand needs `--profile ` and you didn't pass it | Pass `--profile` | - -Full table: [error codes](/en/docs/error-codes/). - -## Next - -- Multi-environment workflows for env → [Multi-env vars](/en/tutorials/env-multi-env/) -- All 10 deploy backends in detail → [Multi-backend deploy](/en/tutorials/deploy-multi-backend/) diff --git a/apps/docs/content/tutorials/en/container-build-push.mdx b/apps/docs/content/tutorials/en/container-build-push.mdx deleted file mode 100644 index 9715be49..00000000 --- a/apps/docs/content/tutorials/en/container-build-push.mdx +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: Build & push images -description: one container info / build / push — what gets built, how versions are inferred, and how the image tag maps to your registry namespace. ---- - -`one container` is the wrapper around `docker build` / `docker push` that knows about your manifest. Three subcommands, no surprises. - -Prerequisites: you've configured a `container/docker` profile and the workspace has at least one project that declares `domains.container` (templates `nestjs-api`, `go-api`, `nextjs-app` do by default). See [Manage profiles](/en/tutorials/configure-profiles/) if you haven't set up the profile yet. - -## 1. `one container info` — see what builds - -```bash -one container info -one container info -o json -``` - -Lists every project in the workspace that has a container declaration, plus the current state: - -| Field | What it means | -|---|---| -| `project` | Project name from manifest | -| `image` | The full image tag (`//:`) | -| `dockerfile` | Path to the Dockerfile inside the project | -| `lastBuildVersion` | Version recorded after the last `one container build` | - -This is read-only — safe to run any time. - -## 2. `one container build []` — build an image - -Build all containerized projects: - -```bash -one container build -``` - -Build one: - -```bash -one container build api -one container build -p apps/web # or by relative path -``` - -### Version inference - -`--build-version` controls the image tag suffix. If you don't pass it, One CLI tries these in order: - -1. The `buildVersion` field on the project in `one.manifest.json` (if pinned) -2. The closest git tag matching `v*` on the current commit -3. The `version` field in `package.json` (Node projects) -4. The first 7 characters of the current git SHA (`-dirty` appended if working tree has uncommitted changes) - -Override anytime: - -```bash -one container build api --build-version v1.4.2 -``` - -In CI, always pass `--build-version` explicitly — it's the only way to make image tags deterministic across re-runs. - -### Dry-run - -```bash -one container build api --dry-run -``` - -Prints the `docker build` command One CLI would run, without invoking docker. Useful for debugging tag construction. - -## 3. `one container push []` — push to registry - -```bash -one container push -one container push api --build-version v1.4.2 -``` - -`push` requires a default `container/docker` profile (or `--profile ` for a one-off). The profile supplies: - -- `registry` — e.g. `ghcr.io`, `.dkr.ecr.us-east-1.amazonaws.com` -- `namespace` — e.g. your GitHub org, AWS ECR repo prefix -- `username` + `password` (or token) - -The full pushed tag is `//:`. - -### Tag override per project - -By default, the image name (`` in the tag) is the project name. Override in the manifest: - -```json -{ - "name": "api", - "domains": { - "container": { - "image": "user-service", - "namespace": "internal" - } - } -} -``` - -This produces `/internal/user-service:v1.4.2` regardless of the project name. - -## Build version flow with `one deploy` - -When deploying to kustomize (the only deploy backend that consumes container images), the version that gets applied to k8s is: - -1. `one deploy --build-version vX.Y.Z` if you pass it -2. Otherwise the last recorded build version (`one container build` writes it back to manifest as `lastBuildVersion`) - -Typical CI sequence: - -```yaml -- run: one container build api --build-version ${{ github.sha }} -- run: one container push api --build-version ${{ github.sha }} -- run: one deploy -p api --env prod --build-version ${{ github.sha }} -``` - -## Profile resolution - -Same chain as everything else (see [Manage profiles](/en/tutorials/configure-profiles/) for the full picture): - -1. `--profile` flag on this command -2. Local project binding in `~/.config/one/config.json#workspaces` -3. Local workspace binding in `~/.config/one/config.json#workspaces` -4. Machine default - -## Common errors - -| Code | Symptom | Fix | -|---|---|---| -| `BACKEND_NOT_ENABLED` | The project has no `domains.container` block | Add it via `one add --container-provider docker`, or edit the manifest manually | -| `REGISTRY_CREDENTIAL_MISSING` | `push` ran without a default `container/docker` profile | `one configure add container/docker --profile ... --use` | -| `DOCKERFILE_MISSING` | Project's declared Dockerfile path doesn't exist | Check `projects[*].domains.container.dockerfile` and the actual file | -| `BUILD_VERSION_UNRESOLVED` | No version could be inferred and `--build-version` wasn't passed | Pass `--build-version`, or commit a tag, or set `package.json#version` | - -Full table: [error codes](/en/docs/error-codes/). - -## Next - -- Deploy to k8s after pushing → [Multi-backend deploy](/en/tutorials/deploy-multi-backend/) -- All container profile fields → [Manage profiles](/en/tutorials/configure-profiles/) diff --git a/apps/docs/content/tutorials/en/deploy-multi-backend.mdx b/apps/docs/content/tutorials/en/deploy-multi-backend.mdx deleted file mode 100644 index 70e73a5b..00000000 --- a/apps/docs/content/tutorials/en/deploy-multi-backend.mdx +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: Service deploy -description: All 10 deploy backends compared. Mixed workspaces. Multi-environment trees. Per-project local Profile bindings and dry-run. ---- - -`one deploy` runs every project's deploy step in one go, dispatching each project to its declared backend. A workspace with a Next.js front end on Vercel and a NestJS API on Kubernetes ships both with a single `one deploy`. - -This page picks up from [basics deploy](/en/tutorials/deploy/) (which walked one project to one target) and covers the rest of the menu. - -## The 10 deploy backends at a glance - -| Backend | Workload type | Profile fields | Notes | -|---|---|---|---| -| `kustomize` | k8s manifests | kubeconfig path, context, namespace | Consumes images from `one container push` | -| `aws-s3` | Static site | region, AK/SK, optional endpoint | Sync build output to bucket | -| `aliyun-oss` | Static site | endpoint, region, AK/SK | S3-compatible | -| `tencent-cos` | Static site | endpoint, region, AK/SK | S3-compatible | -| `minio` | Static site | endpoint, AK/SK | Self-hosted; path-style | -| `rustfs` | Static site | endpoint, AK/SK | Self-hosted; path-style | -| `r2` | Static site | endpoint, AK/SK | Cloudflare R2; region=auto | -| `vercel` | Serverless / SSR | token, teamSlug | Auto-creates project on first deploy | -| `cloudflare` | Pages / Workers | accountId, apiToken | One CLI calls Cloudflare API | -| `edgeone` | Tencent EdgeOne Pages | secret + project info | Tencent-network optimized | - -Templates pin a sensible default; you switch by editing `projects[*].domains.deploy.kind` in `one.manifest.json`. - -## Deploy one project, deploy all projects - -```bash -# All projects with a deploy block (mixed backends OK): -one deploy - -# Filter to one project: -one deploy -p api -one deploy -p apps/web # by relativeDir -``` - -When no `-p` is given, projects deploy in manifest order. A failure stops the run (no continue-on-error today); fix the failing project and rerun. - -## Environment selection - -```bash -one deploy --env staging -``` - -`--env` does two things in one flag: - -1. Tells `one env` which environment's env vars to inject (where applicable). -2. For env-aware backends (kustomize, vercel, cloudflare, edgeone), selects the deploy target environment. - -Per-backend behavior: - -- **kustomize**: `--env staging` → applies `/k8s/overlays/staging/`. Each template scaffolds these overlays. -- **vercel**: `--env staging` → Vercel preview environment; `--env prod` → Production environment. -- **cloudflare** / **edgeone**: same idea — env name maps to the platform's environment label. -- **S3 family** (aws / aliyun / tencent / minio / rustfs / r2): no env concept; `--env` only changes which env vars get baked into the build. - -Default env resolution: `projects[*].domains.deploy..env` → `manifest.environments.default` → `prod`. - -## Per-project local Profile binding - -The Manifest records the deploy Backend and its repository-owned configuration, but never a local Profile name. To make this checkout use `prod-cluster` for the `api` Project in `prod`, run `one serve`, select **Production**, open `api`, and choose that Profile. The Dashboard writes only the name to `~/.config/one/profile-bindings.json`; it does not edit `one.manifest.json`. - -This boundary is intentional: the repository makes the Backend and target environment reviewable, while each machine or CI runner maps them to its own credentials. Another checkout, even with the same Manifest Workspace ID, gets an independent binding because the local store is keyed by canonical root. CI can also use an explicit one-shot `--profile`. - -## Mixed-backend workspace example - -```text -my-workspace/ -├── apps/ -│ └── web/ # nextjs-app, deploys to vercel -├── services/ -│ └── api/ # nestjs-api, deploys to kustomize -└── docs/ # starlight-docs, deploys to aws-s3 -``` - -```bash -one deploy --env prod -``` - -Each project dispatches: - -- `apps/web` → `deploy/vercel` profile `prod` → Vercel API -- `services/api` → image already pushed via `one container push` → `kustomize build | kubectl apply` against the prod cluster -- `docs` → `aws-s3` profile `prod` → `aws s3 sync` to the bucket - -One command, three different deploys, in order. - -## Dry-run - -```bash -one deploy --dry-run -o json | jq -``` - -Prints the planned actions per project — image tag, target environment, resolved profile, the actual command lines — without executing anything. Use this before every prod deploy and you'll catch wrong-profile mistakes before they become incidents. - -## Profile / env override at runtime - -```bash -one deploy -p api --env prod --profile prod-cluster --build-version v1.4.2 -``` - -Each flag overrides only for this invocation: - -- `--profile ` — every project that needs a profile uses this name (if its backend matches). -- `--env ` — applied to every project's env-aware backend. -- `--build-version` — only consumed by kustomize. - -## Common errors - -| Code | Symptom | Fix | -|---|---|---| -| `BACKEND_NOT_ENABLED` | Project has no `domains.deploy` | Add `deploy` block, or remove `-p ` | -| `PROFILE_NOT_FOUND` | `--profile ` doesn't exist for the matching `(deploy/)` | `one configure list deploy/` | -| `IMAGE_TAG_NOT_FOUND` | kustomize deploy can't find the pushed image | Pass `--build-version`, or run `one container build && one container push` first | -| `KUSTOMIZE_OVERLAY_MISSING` | `--env ` but `k8s/overlays//` doesn't exist | Create the overlay, or use one that exists | -| `VERCEL_DEPLOY_FAILED` | Vercel API rejected the deploy | Check `context.vercel_error` in JSON output | -| `S3_BUCKET_NOT_FOUND` / `S3_ACCESS_DENIED` | Bucket name wrong or credentials lack permission | Inspect bucket and the profile's AK/SK | - -Full table: [error codes](/en/docs/error-codes/). - -## Next - -- JSON output for scripting deploys → [JSON output & error codes](/en/tutorials/json-output-error-codes/) diff --git a/apps/docs/content/tutorials/en/deploy.mdx b/apps/docs/content/tutorials/en/deploy.mdx deleted file mode 100644 index 72bd242e..00000000 --- a/apps/docs/content/tutorials/en/deploy.mdx +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: One-click deploy -description: Pick a deploy backend, configure a profile, and ship one project with one deploy. The shortest path. ---- - - - -This tutorial walks one project to one target. The full menu — all 10 deploy backends, per-project flags, multi-environment trees — lives in [Multi-backend deploy](/en/tutorials/deploy-multi-backend/) (advanced). - -We'll pick the deploy backend that matches your project's template default. Each template ships with a sensible default; you can override later. - -| Your project's template | Default deploy backend | Pick this path | -|---|---|---| -| `nextjs-app`, `astro-site`, `react-spa`, `starlight-docs` | Static / serverless | [Path A: Vercel](#path-a-vercel-for-frontends) | -| `nestjs-api`, `go-api` | Container + Kubernetes | [Path B: Kubernetes via kustomize](#path-b-kustomize-for-services) | - -If you're using a backend that's not listed (S3, Cloudflare, EdgeOne, OSS, COS, MinIO, R2), the shape is the same: configure a profile, then `one deploy`. The [`one configure` reference](/en/docs/configure/) lists the credential fields for each. - -## Path A: Vercel (for frontends) - -### 1. Get a Vercel token - -Vercel dashboard → **Settings → Tokens → Create**. Note the token, your team slug, and the project id (or let One CLI create the project for you). - -### 2. Configure the profile - -```bash -one configure add deploy/vercel --profile prod \ - --token \ - --team-slug \ - --use -``` - -This writes to `~/.config/one/config.json` + `credentials.json` (machine-local, mode 0600). It does not touch the repo. - -### 3. Make sure the project's manifest entry has `deploy.kind: vercel` - -```bash -cat one.manifest.json | jq '.projects[] | select(.name == "web") | .domains.deploy' -``` - -If `--deploy-provider vercel` was passed at `one add` time, it's already there. Otherwise add it: - -```json -{ - "name": "web", - "domains": { - "deploy": { - "kind": "vercel", - "config": { "projectId": "prj_xxx" } - } - } -} -``` - -(`projectId` is optional. One CLI creates the Vercel project on first deploy if it's missing.) - -### 4. Deploy - -```bash -one deploy -p web --env prod -``` - -Output points you at the Vercel deployment URL. Subsequent deploys reuse the same Vercel project. - -## Path B: kustomize (for services) - -The kustomize path has two steps because Kubernetes runs containers: you build + push the image, then `one deploy` applies the manifests. - -### 1. Configure a container registry - -Pick a registry (Docker Hub, GHCR, ACR, Harbor, etc.) and create an access token there. - -```bash -one configure add container/docker --profile prod \ - --registry ghcr.io \ - --username \ - --password \ - --use -``` - -### 2. Configure kustomize - -```bash -one configure add deploy/kustomize --profile prod \ - --kubeconfig-path ~/.kube/config \ - --context production \ - --namespace default \ - --use -``` - -`kubeconfig-path` and `context` together pick which cluster you're deploying to. `namespace` defaults to `default`. - -### 3. Build and push the image - -```bash -one container build -p api -one container push -p api -``` - -The build version is inferred from git / `package.json` (or pass `--build-version`). See [Build & push images](/en/tutorials/container-build-push/) for details. - -### 4. Deploy - -```bash -one deploy -p api --env prod -``` - -`one deploy` reads `services/api/k8s/overlays//` (the template scaffolds these) and runs `kustomize build | kubectl apply -f -` against the cluster from the default profile. - -Verify: - -```bash -kubectl --context production get pods -``` - -## Override the profile at runtime - -By default, `one deploy` uses the default profile. To force a specific one (e.g. for a staging cluster): - -```bash -one deploy -p api --env staging --profile staging -``` - -Profile resolution order: - -1. `--profile` flag (this command only) -2. Local project binding in `~/.config/one/config.json#workspaces` -3. Local workspace binding in `~/.config/one/config.json#workspaces` -4. The machine's default profile for the matching `(domain, backend)` pair - -## Common errors - -| Code | Symptom | Fix | -|---|---|---| -| `BACKEND_NOT_ENABLED` | The project's `domains.deploy` is empty | Add the `deploy` block to `one.manifest.json` for that project | -| `PROFILE_NOT_FOUND` | The profile referenced doesn't exist on this machine | `one configure list deploy/` to see what's available | -| `REGISTRY_CREDENTIAL_MISSING` | `one container push` ran without a default `container/docker` profile | `one configure add container/docker ... --use` | -| `IMAGE_TAG_NOT_FOUND` | `one deploy` for kustomize couldn't find the pushed image | Run `one container build && one container push` first | -| `VERCEL_DEPLOY_FAILED` | Vercel rejected the deploy | Inspect Vercel UI logs; the message in `context.vercel_error` usually says why | - -Full table: [error codes](/en/docs/error-codes/). - -## Next - -- The other 8 deploy backends + multi-project / multi-env → [Multi-backend deploy](/en/tutorials/deploy-multi-backend/) (advanced) -- All `one configure` backends including S3 variants and Cloudflare → [Manage profiles](/en/tutorials/configure-profiles/) (advanced) -- Build / push images in more detail → [Build & push images](/en/tutorials/container-build-push/) (advanced) diff --git a/apps/docs/content/tutorials/en/env-vars.mdx b/apps/docs/content/tutorials/en/env-vars.mdx index 4b34b073..e153b4ac 100644 --- a/apps/docs/content/tutorials/en/env-vars.mdx +++ b/apps/docs/content/tutorials/en/env-vars.mdx @@ -30,7 +30,7 @@ The value is written to `services/api/.env` (or whichever directory matches the ### 2. Read it back ```bash -one env get DATABASE_URL -p api +one env get DATABASE_URL -p api --reveal # postgres://localhost/dev one env list -p api @@ -56,10 +56,10 @@ Use this when env vars live in one shared place — multiple machines, CI, multi In the Infisical UI: **Organization → Access Control → Identities → New** (use Universal Auth). Note the `client id` and `client secret`. -### 2. Configure a profile on this machine +### 2. Sign in with a browser ```bash -one configure add env/infisical --profile default \ +one login --site-url https://app.infisical.com \ --client-id \ --client-secret \ @@ -78,7 +78,7 @@ one env switch infisical This does a few things: -1. Verifies the default `env/infisical` profile exists on this machine +1. Verifies the browser session is available 2. Scans every project's `.env` files and asks: "found N keys, sync to Infisical?" 3. Only after sync succeeds (or you opt out), flips `one.manifest.json#domains.env.kind` to `infisical` 4. Lazily binds / creates the Infisical project for this workspace @@ -127,8 +127,8 @@ Flips the manifest only. **Does not** delete Infisical data (safe). If you want | `ENV_INVALID_KEY` | Key has unsupported characters | Use POSIX env-var names: `^[A-Z][A-Z0-9_]*$` (e.g. `DATABASE_URL`) | | `ENV_SET_KEY_REQUIRED` | Ran `one env set` without a key | Pass `KEY=VALUE` or `KEY VALUE` | | `INFISICAL_NOT_CONFIGURED` | Workspace isn't on `env/infisical`, or the manifest config is incomplete | Switch the manifest or re-run `one create --env-provider infisical` | -| `INFISICAL_AUTH_MISSING` | No default `env/infisical` profile on this machine | Re-run `one configure add env/infisical ... --use` | -| `INFISICAL_AUTH_FAILED` | Client id / secret is wrong or expired | Regenerate the secret in Infisical, update the profile | +| `INFISICAL_AUTH_MISSING` | No browser session | Re-run `one login` | +| `INFISICAL_AUTH_FAILED` | Session rejected or expired | Log out and sign in again | | `ENV_PULL_CONFLICT` | Local `.env` differs from Infisical contents | Inspect the diff; rerun with `--force` to overwrite | Full table: [error codes](/en/docs/error-codes/). @@ -136,4 +136,4 @@ Full table: [error codes](/en/docs/error-codes/). ## Next - Multi-environment trees, layered `.env.local`, per-project path overrides → [Multi-env vars](/en/tutorials/env-multi-env/) (advanced) -- All `one configure` backends, not just env → [Manage profiles](/en/tutorials/configure-profiles/) (advanced) +- Shared credentials → [Browser login](/en/tutorials/infisical-login/) (advanced) diff --git a/apps/docs/content/tutorials/en/first-workspace.mdx b/apps/docs/content/tutorials/en/first-workspace.mdx index f46d8e12..9552a352 100644 --- a/apps/docs/content/tutorials/en/first-workspace.mdx +++ b/apps/docs/content/tutorials/en/first-workspace.mdx @@ -5,7 +5,7 @@ description: Run one create yourself in the terminal. See what gets written to d -This tutorial covers exactly one command: `one create`. Picking which templates to use is the [previous chapter](/en/tutorials/templates/); deploying for the first time is the [next chapter](/en/tutorials/deploy/). +This tutorial covers `one create`. See [templates](/en/tutorials/templates/) to choose projects, then continue with [environment variables](/en/tutorials/env-vars/). ## 1. Install the CLI diff --git a/apps/docs/content/tutorials/en/infisical-login.mdx b/apps/docs/content/tutorials/en/infisical-login.mdx new file mode 100644 index 00000000..0ab53d80 --- /dev/null +++ b/apps/docs/content/tutorials/en/infisical-login.mdx @@ -0,0 +1,54 @@ +--- +title: Log in and use shared credentials +description: Browser login, system keyring storage, and global Infisical variables. +--- + +## Browser login + +```bash +one login +one whoami +one logout +one login --site-url https://secrets.example.com +``` + +One keeps one active Infisical account. Complete login in the browser; the session token is stored in the system keyring. Client ID, Client Secret, and Profiles are no longer used. Log out before changing accounts or instances. An unavailable keyring causes an error with no plaintext fallback. Expired sessions require explicit login; reading variables never launches a browser automatically. Old credential files are neither read nor automatically deleted. + +## Global variables + +Choose an existing Infisical project and environment: + +```bash +one env bind --global +one env bind --global --project-id PROJECT_ID --env dev +one env --global +one env list --global --env dev --path / +one env list --global --env dev --path /docker +one run --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script +``` + +Listings contain immediate folders, names, and descriptions, without values. Execution requires an explicit environment and path. It does not recurse, import other folders, or expand secret references. Use `--keys` to narrow injection further. Global mode works outside a workspace and preserves the current directory. Project commands, `one dev`, and `one build` do not automatically receive global variables. + +Read plaintext explicitly with `one env get KEY --global --env dev --path /docker --reveal`. Write with the interactive password prompt or `one env set KEY --global --env dev --path /docker --stdin`. Overwrites require `--yes`. Delete with `one env unset KEY --global --env dev --path /docker`. + +## Dashboard + +Run `one serve`. Settings manages browser login, pending callbacks, cancellation, logout, and language. Global variables manages the storage project, browsing environment, folders, and variables. Values are fetched only on reveal or copy and cleared when the account, environment, folder, or page changes. Remote edits take effect immediately. Workspace project bindings are reviewed as Manifest drafts and saved atomically with other draft changes. + +## Security boundaries + +Injection and output masking reduce accidental exposure; they do not isolate an Agent running arbitrary programs as the same OS user. Such an Agent can still retrieve or exfiltrate credentials. Descriptions are untrusted data. Limit Infisical and cloud permissions, environments, paths, and credential lifetime. Output masking is best effort for exact known values and cannot cover transformed output or files written by child processes. External tools such as Docker may persist credentials themselves. + +## Preferences and workspace tools + +```bash +one locale en-US +one locale zh-CN +one locale auto +one init mise --dry-run +one init mise +one init hooks --dry-run +one init hooks +``` + +`one init mise` generates tool configuration while preserving user configuration. `one init hooks` configures hk checks and Git hooks for the current checkout. `one mise` and `one hk` continue to forward tool commands. Language preferences are stored locally. diff --git a/apps/docs/content/tutorials/en/json-output-error-codes.mdx b/apps/docs/content/tutorials/en/json-output-error-codes.mdx index ab88a029..387860ba 100644 --- a/apps/docs/content/tutorials/en/json-output-error-codes.mdx +++ b/apps/docs/content/tutorials/en/json-output-error-codes.mdx @@ -43,7 +43,7 @@ Every command emits a shape with a `schema` field for forward compatibility: } ``` -The `schema` URL is stable for a major version; the data inside the matching payload key (`templates` here, `projects` for `one add`, `profiles` for `one configure list`) is the part you process. +The `schema` URL is stable for a major version; the data inside the matching payload key (`templates` here, `projects` for `one add`, `profiles` for `one whoami`) is the part you process. ## 3. The error envelope @@ -125,7 +125,7 @@ There are ~150 codes; you rarely handle all of them in a script. The most common |---|---|---| | Workspace state | `NOT_ONE_PROJECT`, `WORKSPACE_NESTED_FORBIDDEN`, `MANIFEST_MISSING_OR_EMPTY` | "Run from the workspace root" or "Run `one create` first" | | Templates | `TEMPLATE_NOT_FOUND`, `TEMPLATE_REQUIRED`, `INVALID_NAME` | List available templates from `error.context` | -| Backends | `BACKEND_NOT_ENABLED`, `PROFILE_NOT_FOUND`, `DOMAIN_INVALID`, `BACKEND_ID_UNKNOWN` | Direct user to `one configure list ` | +| Backends | `BACKEND_NOT_ENABLED`, `PROFILE_NOT_FOUND`, `DOMAIN_INVALID`, `BACKEND_ID_UNKNOWN` | Direct user to `one whoami` | | Infisical | `INFISICAL_NOT_CONFIGURED`, `INFISICAL_AUTH_MISSING`, `INFISICAL_AUTH_FAILED`, `ENV_PULL_CONFLICT` | Re-auth or rerun with `--force` | | Container / deploy | `IMAGE_TAG_NOT_FOUND`, `REGISTRY_CREDENTIAL_MISSING`, `BUILD_VERSION_UNRESOLVED`, `VERCEL_DEPLOY_FAILED` | Build/push before deploy; fix profile | | Serve | `SERVE_PORT_BUSY`, `SERVE_BIND_FORBIDDEN` | Change the host or port flag, then restart | diff --git a/apps/docs/content/tutorials/en/meta.json b/apps/docs/content/tutorials/en/meta.json index 2446899c..c7187d7b 100644 --- a/apps/docs/content/tutorials/en/meta.json +++ b/apps/docs/content/tutorials/en/meta.json @@ -4,14 +4,11 @@ "---Basics---", "templates", "first-workspace", - "deploy", "---Advanced---", - "configure-profiles", + "infisical-login", "env-vars", "env-multi-env", "dev-local", - "container-build-push", - "deploy-multi-backend", "json-output-error-codes" ] } diff --git a/apps/docs/content/tutorials/zh/configure-profiles.mdx b/apps/docs/content/tutorials/zh/configure-profiles.mdx deleted file mode 100644 index 85f4468e..00000000 --- a/apps/docs/content/tutorials/zh/configure-profiles.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: 管理多平台密钥 -description: 用 one configure 集中管理 12 个平台的凭据 — 每个平台存什么、文件落在哪、运行时怎么选。 ---- - -`one configure` 管理本机的 **profile** — 即 One CLI 要去对接的各种 endpoint 的凭据集合。一个工作区可以用多个 profile(staging 集群一个 / prod 集群一个);同一个 profile 也能跨工作区共享。 - -[基础 secrets](/zh/tutorials/env-vars/) 和 [基础 deploy](/zh/tutorials/deploy/) 都只用了单个 profile 走 happy path。这一章是 profile 管理的完整参考。 - -## 12 个 (domain, backend) pair - -| Pair | profile 里存什么 | -|---|---| -| `env/infisical` | Universal Auth machine identity — `siteUrl` / `clientId` / `clientSecret` | -| `deploy/aws-s3` | `region`、`accessKeyId`、`accessKeySecret`,`endpoint` 可选 | -| `deploy/aliyun-oss` | `endpoint`、`region`、AK/SK | -| `deploy/tencent-cos` | `endpoint`、`region`、AK/SK | -| `deploy/minio` | `endpoint`、AK/SK(默认 path-style) | -| `deploy/rustfs` | 同 minio | -| `deploy/r2` | Cloudflare R2:`endpoint`、AK/SK(`region` 是 `auto`) | -| `deploy/kustomize` | `kubeconfigPath`、`context`、`namespace` | -| `deploy/vercel` | `token`、`teamSlug`(可选) | -| `deploy/cloudflare` | `accountId`、`apiToken` | -| `deploy/edgeone` | EdgeOne secret + 项目信息 | -| `container/docker` | `registry`、`namespace`、`username`、`password`(token 也行) | - -跑 `one configure add --help` 看 live 列表。每个 pair 有自己的 sub-subcommand,含 backend 专属 flag — 跑 `one configure add --help` 看具体 flag。 - -## 存储:双文件 mode 0600 - -``` -~/.config/one/ -├── config.json # endpoint / region / default 指针(非敏感) -└── credentials.json # clientSecret / accessKeySecret / 密码(敏感) -``` - -两个文件都是 `0600` 权限 — 仅本人可读。**不进 git**,**不要**复制到团队共享 dotfile 仓库。 - -这套拆分跟 AWS CLI 的双文件模型一致:敏感字段在 `credentials.json`,截图 / 误提交也无所谓的字段在 `config.json`。 - -## 加 / 改 profile - -```bash -# 交互式(新机器推荐): -one configure -# → 问 (domain, backend) → 走对应流程 - -# 或直接对某个 pair: -one configure add deploy/aws-s3 --profile prod \ - --region us-east-1 \ - --access-key-id \ - --access-key-secret \ - --use -``` - -规则: - -- 某 `(pair, profile)` 第一次配:status = `completed`,自动成为 default -- 同 `(pair, profile)` 再跑一次:status = `updated`(覆盖凭据) -- `--use` 显式把这个 profile 设为 default - -## 列表 / 切换 / 查看 - -```bash -# 列所有 profile: -one configure list - -# 只列某个 pair: -one configure list deploy/aws-s3 - -# 看 default: -one configure current -one configure current deploy/aws-s3 - -# 打印一个 profile 全文(凭据默认掩码): -one configure show deploy/aws-s3 --profile prod -one configure show deploy/aws-s3 --profile prod --reveal # 显示原文 - -# 切 default: -one configure use deploy/aws-s3 --profile staging - -# 删除: -one configure remove deploy/aws-s3 --profile old -``` - -所有这些都支持 `-o json` — 适合写脚本做 profile 轮换。 - -## Profile 解析链 - -跑需要 profile 的命令时(`one deploy` / `one env pull` / `one container push`),One CLI 按这个顺序选: - -1. **`--profile` flag**(最高优先,一次性) -2. **环境感知 Project 绑定** — `profile-bindings.json` 中规范化 root + environment + Project 对应的 Profile 名 -3. **环境感知 Workspace 绑定** — 该规范化 root + environment 的 Workspace 级名字 -4. **旧本机 Project 绑定** — `config.json#workspaces[workspaceId].projects[project].profiles[...]` -5. **旧本机 Workspace 绑定** — 用 `one configure use --profile --workspace` 设置 -6. **本机 default 指针** — `one configure use --profile ` 设的(或第一个加入的 profile,如果没显式设过) - -manifest 不再保存本机 Profile 名。Dashboard 只把第 2–3 层的名字写到本机文件,永远不改代码库。`one configure current ` 报的是 default 指针(第 6 层)。要 debug 具体某条命令会选哪个,跑 `-o json --dry-run`: - -```bash -one deploy -p api --env staging -o json --dry-run | jq '.profile' -``` - -## locale:切 CLI 语言 - -`configure` 的第 14 个子命令不是 `(domain, backend)` 对,是 CLI 自己的 UI 语言: - -```bash -one configure locale --set zh-CN -one configure locale --set en-US -``` - -**只影响人类看到的文本**(`error.message`、提示语、表格表头)。错误**码**(`error.code`)和 JSON 输出跨 locale 完全一样 — 因此 agent 应按 `error.code` 分流,不去解析 `error.message`。 - -## 常见错误 - -| 错误码 | 现象 | 修法 | -|---|---|---| -| `DOMAIN_INVALID` | pair 形式错(如 `env/foo` 但 `foo` 不是已知 backend) | 用 12 个支持的 pair 中的一个 | -| `BACKEND_ID_UNKNOWN` | 跟上一条一样,从另一条代码路径冒出来 | 同上 | -| `PROFILE_NOT_FOUND` | 某条命令(或 `--profile` flag)引用了不存在的 profile 名 | `one configure list ` 看本机有什么 | -| `PROFILE_NAME_REQUIRED` | 子命令需要 `--profile ` 你没传 | 加 `--profile` | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 下一步 - -- env 的多环境用法 → [多环境变量](/zh/tutorials/env-multi-env/) -- 10 个 deploy backend 细节 → [多 backend 部署](/zh/tutorials/deploy-multi-backend/) diff --git a/apps/docs/content/tutorials/zh/container-build-push.mdx b/apps/docs/content/tutorials/zh/container-build-push.mdx deleted file mode 100644 index ae7c6dc9..00000000 --- a/apps/docs/content/tutorials/zh/container-build-push.mdx +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: 构建与推送镜像 -description: one container info / build / push — 构什么、版本号怎么推断、镜像 tag 怎么映射到 registry namespace。 ---- - -`one container` 是 `docker build` / `docker push` 的 wrapper,但它认 manifest。三个子命令,没花活。 - -前置:已经配过 `container/docker` profile,工作区里至少有一个项目声明了 `domains.container`(`nestjs-api`、`go-api`、`nextjs-app` 模板默认就有)。没配 profile 见[管理 Profile](/zh/tutorials/configure-profiles/)。 - -## 1. `one container info` — 看会构什么 - -```bash -one container info -one container info -o json -``` - -列出工作区里所有有 container 声明的项目和当前状态: - -| 字段 | 含义 | -|---|---| -| `project` | manifest 里的项目名 | -| `image` | 完整镜像 tag(`//:`) | -| `dockerfile` | 项目里 Dockerfile 路径 | -| `lastBuildVersion` | 上次 `one container build` 写下的版本 | - -只读,随时跑都安全。 - -## 2. `one container build []` — 构镜像 - -构所有含 container 的项目: - -```bash -one container build -``` - -构一个: - -```bash -one container build api -one container build -p apps/web # 或按相对路径 -``` - -### 版本号推断 - -`--build-version` 控制镜像 tag 后缀。不传时按顺序尝试: - -1. manifest 里项目的 `buildVersion` 字段(钉了的话) -2. 当前 commit 上最近的 `v*` git tag -3. `package.json#version`(Node 项目) -4. 当前 git SHA 前 7 位(工作区有未提交内容时加 `-dirty`) - -随时可以覆盖: - -```bash -one container build api --build-version v1.4.2 -``` - -**CI 里永远显式传 `--build-version`** — 这是让镜像 tag 在重跑时确定唯一的唯一办法。 - -### Dry-run - -```bash -one container build api --dry-run -``` - -打印 One CLI 会跑的 `docker build` 命令,不实际调 docker。适合 debug tag 拼接。 - -## 3. `one container push []` — 推到 registry - -```bash -one container push -one container push api --build-version v1.4.2 -``` - -`push` 需要 default 的 `container/docker` profile(或 `--profile ` 一次性指定)。profile 提供: - -- `registry` — 比如 `ghcr.io`、`.dkr.ecr.us-east-1.amazonaws.com` -- `namespace` — 比如 GitHub org、AWS ECR repo prefix -- `username` + `password`(或 token) - -完整推送 tag 是 `//:`。 - -### 单项目覆盖 tag - -默认镜像名(tag 里的 ``)就是项目名。manifest 里覆盖: - -```json -{ - "name": "api", - "domains": { - "container": { - "image": "user-service", - "namespace": "internal" - } - } -} -``` - -不管项目叫什么,生成 `/internal/user-service:v1.4.2`。 - -## 跟 `one deploy` 联动的版本流 - -部署到 kustomize(唯一消费 container 镜像的 deploy backend)时,最终 apply 到 k8s 的版本是: - -1. `one deploy --build-version vX.Y.Z` 显式传的 -2. 否则 manifest 里 `lastBuildVersion`(`one container build` 会写回) - -典型 CI 流程: - -```yaml -- run: one container build api --build-version ${{ github.sha }} -- run: one container push api --build-version ${{ github.sha }} -- run: one deploy -p api --env prod --build-version ${{ github.sha }} -``` - -## Profile 解析 - -跟其他命令一样的链(见[管理 Profile](/zh/tutorials/configure-profiles/)): - -1. `--profile` flag -2. 本机 project 绑定:`~/.config/one/config.json#workspaces` -3. 本机 workspace 绑定:`~/.config/one/config.json#workspaces` -4. 本机 default - -## 常见错误 - -| 错误码 | 现象 | 修法 | -|---|---|---| -| `BACKEND_NOT_ENABLED` | 项目没 `domains.container` 块 | `one add --container-provider docker` 或手改 manifest | -| `REGISTRY_CREDENTIAL_MISSING` | `push` 没 default `container/docker` profile | `one configure add container/docker --profile ... --use` | -| `DOCKERFILE_MISSING` | 项目声明的 Dockerfile 路径不存在 | 核对 `projects[*].domains.container.dockerfile` 和实际文件 | -| `BUILD_VERSION_UNRESOLVED` | 推断不出版本且没传 `--build-version` | 传 `--build-version`,或打 git tag,或写 `package.json#version` | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 下一步 - -- 推完镜像部署到 k8s → [多 backend 部署](/zh/tutorials/deploy-multi-backend/) -- container profile 全字段 → [管理 Profile](/zh/tutorials/configure-profiles/) diff --git a/apps/docs/content/tutorials/zh/deploy-multi-backend.mdx b/apps/docs/content/tutorials/zh/deploy-multi-backend.mdx deleted file mode 100644 index 9ef144aa..00000000 --- a/apps/docs/content/tutorials/zh/deploy-multi-backend.mdx +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: 服务部署 -description: 10 个 deploy backend 对比、混合后端工作区、多环境树、per-project 本机 Profile 绑定、dry-run。 ---- - -`one deploy` 一次性把工作区每个项目按自己声明的 backend 推上线。一个 Next.js 前端走 Vercel、NestJS API 走 k8s 的工作区,跑一次 `one deploy` 两个都上。 - -这一章接[基础 deploy](/zh/tutorials/deploy/)(那里只把一个项目推到一个目标),讲剩下的菜单。 - -## 10 个 deploy backend 概览 - -| Backend | 工作负载 | profile 字段 | 备注 | -|---|---|---|---| -| `kustomize` | k8s manifest | kubeconfig path、context、namespace | 消费 `one container push` 的镜像 | -| `aws-s3` | 静态站 | region、AK/SK、endpoint 可选 | 同步构建产物到 bucket | -| `aliyun-oss` | 静态站 | endpoint、region、AK/SK | S3 协议 | -| `tencent-cos` | 静态站 | endpoint、region、AK/SK | S3 协议 | -| `minio` | 静态站 | endpoint、AK/SK | 自托管,path-style | -| `rustfs` | 静态站 | endpoint、AK/SK | 自托管,path-style | -| `r2` | 静态站 | endpoint、AK/SK | Cloudflare R2,region=auto | -| `vercel` | serverless / SSR | token、teamSlug | 第一次 deploy 自动建项目 | -| `cloudflare` | Pages / Workers | accountId、apiToken | One CLI 直调 Cloudflare API | -| `edgeone` | 腾讯 EdgeOne Pages | secret + 项目信息 | 国内网络优化 | - -模板会钉一个合理默认,要切换就改 `projects[*].domains.deploy.kind`。 - -## 单项目部署 / 全工作区部署 - -```bash -# 所有含 deploy 块的项目(混合 backend 也行): -one deploy - -# 过滤单项目: -one deploy -p api -one deploy -p apps/web # 按 relativeDir -``` - -不传 `-p` 时按 manifest 顺序部署。**遇错停**(目前没有 continue-on-error);改完出错的项目重跑即可。 - -## 选环境 - -```bash -one deploy --env staging -``` - -`--env` 一个 flag 干两件事: - -1. 告诉 `one env` 注入哪个环境的环境变量(适用时) -2. env-aware backend(kustomize / vercel / cloudflare / edgeone)选目标环境 - -按 backend 行为: - -- **kustomize**:`--env staging` → 应用 `/k8s/overlays/staging/`。模板会生成这些 overlay。 -- **vercel**:`--env staging` → Vercel preview 环境;`--env prod` → Production 环境。 -- **cloudflare** / **edgeone**:env 名映射到平台的环境标签。 -- **S3 家族**(aws / aliyun / tencent / minio / rustfs / r2):无环境概念,`--env` 只影响构建时注入的环境变量。 - -env 默认解析:`projects[*].domains.deploy..env` → `manifest.environments.default` → `prod`。 - -## Per-project 本机 Profile 绑定 - -Manifest 记录 deploy Backend 和代码库所有的配置,但永远不记录本机 Profile 名。要让当前 checkout 在 `prod` 下为 `api` Project 使用 `prod-cluster`,运行 `one serve`,选择**生产环境**,打开 `api` 并选该 Profile。Dashboard 只把名字写入 `~/.config/one/profile-bindings.json`,不会编辑 `one.manifest.json`。 - -这个边界是刻意的:代码库让 Backend 和目标环境可审查,每台机器或 CI runner 再映射自己的凭据。另一份 checkout 即使带相同 Manifest Workspace ID,也因本机存储按规范化 root 键控而拥有独立绑定。CI 也可显式传一次性 `--profile`。 - -## 混合 backend 工作区示例 - -```text -my-workspace/ -├── apps/ -│ └── web/ # nextjs-app,deploy 到 vercel -├── services/ -│ └── api/ # nestjs-api,deploy 到 kustomize -└── docs/ # starlight-docs,deploy 到 aws-s3 -``` - -```bash -one deploy --env prod -``` - -每个项目分别派发: - -- `apps/web` → `deploy/vercel` profile `prod` → Vercel API -- `services/api` → 镜像已由 `one container push` 推过 → `kustomize build | kubectl apply` 到 prod 集群 -- `docs` → `aws-s3` profile `prod` → `aws s3 sync` 到 bucket - -一条命令,三种部署,按顺序。 - -## Dry-run - -```bash -one deploy --dry-run -o json | jq -``` - -打印每个项目的计划动作 — 镜像 tag、目标环境、解析出的 profile、实际命令行 — 不执行任何东西。每次 prod 部署前跑一次,能在错 profile 变事故前抓住。 - -## 运行时覆盖 profile / env - -```bash -one deploy -p api --env prod --profile prod-cluster --build-version v1.4.2 -``` - -每个 flag 只对这次调用生效: - -- `--profile ` — 所有需要 profile 的项目都用这个(backend 匹配的话) -- `--env ` — 应用到每个 env-aware backend -- `--build-version` — 只 kustomize 消费 - -## 常见错误 - -| 错误码 | 现象 | 修法 | -|---|---|---| -| `BACKEND_NOT_ENABLED` | 项目没 `domains.deploy` | 加 `deploy` 块或别用 `-p ` | -| `PROFILE_NOT_FOUND` | `--profile ` 在对应 `(deploy/)` 下不存在 | `one configure list deploy/` | -| `IMAGE_TAG_NOT_FOUND` | kustomize deploy 找不到镜像 | 传 `--build-version`,或先 `one container build && one container push` | -| `KUSTOMIZE_OVERLAY_MISSING` | `--env ` 但 `k8s/overlays//` 不存在 | 建 overlay 或用已存在的 | -| `VERCEL_DEPLOY_FAILED` | Vercel API 拒绝部署 | 看 JSON 输出里 `context.vercel_error` | -| `S3_BUCKET_NOT_FOUND` / `S3_ACCESS_DENIED` | bucket 名错或凭据权限不够 | 核对 bucket 和 profile 的 AK/SK | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 下一步 - -- 脚本化部署用的 JSON 输出 → [JSON 输出与错误码](/zh/tutorials/json-output-error-codes/) diff --git a/apps/docs/content/tutorials/zh/deploy.mdx b/apps/docs/content/tutorials/zh/deploy.mdx deleted file mode 100644 index 2436c867..00000000 --- a/apps/docs/content/tutorials/zh/deploy.mdx +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: 一键部署 -description: 挑一个 deploy backend、配 profile、用 one deploy 把一个项目推上线。最短路径。 ---- - -这一章带你把一个项目推到一个目标。10 个 deploy backend 全菜单、per-project flag、多环境树都在[多 backend 部署](/zh/tutorials/deploy-multi-backend/)(进阶)。 - -按你项目用的模板默认 backend 选一条路径。每个模板都自带一个合理默认值,后面可以改。 - -| 你的项目模板 | 默认 deploy backend | 走这条路径 | -|---|---|---| -| `nextjs-app`、`astro-site`、`react-spa`、`starlight-docs` | 静态 / serverless | [路线 A:Vercel 前端](#路线-avercel前端) | -| `nestjs-api`、`go-api` | 容器 + Kubernetes | [路线 B:kustomize 服务](#路线-bkustomize服务) | - -用其他 backend(S3、Cloudflare、EdgeOne、OSS、COS、MinIO、R2)形式一样:配 profile,跑 `one deploy`。每个 backend 的凭据字段见 [`one configure` 参考](/zh/docs/configure/)。 - -## 路线 A:Vercel(前端) - -### 1. 拿 Vercel token - -Vercel 后台 → **Settings → Tokens → Create**。记下 token、team slug、project id(也可以让 One CLI 替你建项目)。 - -### 2. 配 profile - -```bash -one configure add deploy/vercel --profile prod \ - --token \ - --team-slug \ - --use -``` - -写到本机 `~/.config/one/config.json` + `credentials.json`(mode 0600)。**不会进 git。** - -### 3. 确认项目 manifest 里有 `deploy.kind: vercel` - -```bash -cat one.manifest.json | jq '.projects[] | select(.name == "web") | .domains.deploy' -``` - -`one add` 时传过 `--deploy-provider vercel` 的话已经在了。否则加上: - -```json -{ - "name": "web", - "domains": { - "deploy": { - "kind": "vercel", - "config": { "projectId": "prj_xxx" } - } - } -} -``` - -(`projectId` 可选,第一次 deploy 时 One CLI 会自动建。) - -### 4. 部署 - -```bash -one deploy -p web --env prod -``` - -输出带 Vercel 部署 URL。后续 deploy 复用同一个 Vercel project。 - -## 路线 B:kustomize(服务) - -kustomize 路径要两步,因为 k8s 跑容器:先 build + push 镜像,再 `one deploy` apply manifest。 - -### 1. 配容器 registry - -挑一个 registry(Docker Hub、GHCR、ACR、Harbor 等)创建 access token。 - -```bash -one configure add container/docker --profile prod \ - --registry ghcr.io \ - --username \ - --password \ - --use -``` - -### 2. 配 kustomize - -```bash -one configure add deploy/kustomize --profile prod \ - --kubeconfig-path ~/.kube/config \ - --context production \ - --namespace default \ - --use -``` - -`kubeconfig-path` + `context` 一起决定推到哪个集群。`namespace` 默认 `default`。 - -### 3. Build + push 镜像 - -```bash -one container build -p api -one container push -p api -``` - -build version 从 git / `package.json` 推断,也可以传 `--build-version` 显式指定。详见[构建与推送镜像](/zh/tutorials/container-build-push/)。 - -### 4. 部署 - -```bash -one deploy -p api --env prod -``` - -`one deploy` 会读 `services/api/k8s/overlays//`(模板生成的目录),跑 `kustomize build | kubectl apply -f -`,集群从 default profile 拿。 - -验证: - -```bash -kubectl --context production get pods -``` - -## 运行时覆盖 profile - -默认用 default profile。要指定具体的(例如 staging 集群): - -```bash -one deploy -p api --env staging --profile staging -``` - -profile 解析顺序: - -1. `--profile` flag(只对这次命令生效) -2. 本机 project 绑定:`~/.config/one/config.json#workspaces` -3. 本机 workspace 绑定:`~/.config/one/config.json#workspaces` -4. 本机 `(domain, backend)` 对的 default profile - -## 常见错误 - -| 错误码 | 现象 | 修法 | -|---|---|---| -| `BACKEND_NOT_ENABLED` | 项目 `domains.deploy` 为空 | 在 `one.manifest.json` 给该项目加 `deploy` 块 | -| `PROFILE_NOT_FOUND` | 引用的 profile 在本机不存在 | `one configure list deploy/` 看本机有哪些 | -| `REGISTRY_CREDENTIAL_MISSING` | `one container push` 没 default `container/docker` profile | `one configure add container/docker ... --use` | -| `IMAGE_TAG_NOT_FOUND` | kustomize 部署找不到镜像 | 先 `one container build && one container push` | -| `VERCEL_DEPLOY_FAILED` | Vercel 拒绝部署 | 看 Vercel UI 日志,`context.vercel_error` 一般写明原因 | - -完整码表:[错误码大全](/zh/docs/error-codes/)。 - -## 下一步 - -- 另外 8 个 deploy backend + 多项目 / 多环境 → [多 backend 部署](/zh/tutorials/deploy-multi-backend/)(进阶) -- 所有 `one configure` 后端含 S3 各家 + Cloudflare → [管理 Profile](/zh/tutorials/configure-profiles/)(进阶) -- 镜像构建 / 推送细节 → [构建与推送镜像](/zh/tutorials/container-build-push/)(进阶) diff --git a/apps/docs/content/tutorials/zh/env-vars.mdx b/apps/docs/content/tutorials/zh/env-vars.mdx index 8596ecea..84c2c1f4 100644 --- a/apps/docs/content/tutorials/zh/env-vars.mdx +++ b/apps/docs/content/tutorials/zh/env-vars.mdx @@ -30,7 +30,7 @@ one env set DATABASE_URL=postgres://localhost/dev -p api ### 2. 读出来 ```bash -one env get DATABASE_URL -p api +one env get DATABASE_URL -p api --reveal # postgres://localhost/dev one env list -p api @@ -56,10 +56,10 @@ dotenv 就这些。想 commit `.env.example` 随便;**别 commit `.env`**( Infisical 后台:**Organization → Access Control → Identities → New**(选 Universal Auth),记下 `client id` 和 `client secret`。 -### 2. 在本机配 profile +### 2. 在浏览器中登录 ```bash -one configure add env/infisical --profile default \ +one login --site-url https://app.infisical.com \ --client-id \ --client-secret \ @@ -78,7 +78,7 @@ one env switch infisical 会做几件事: -1. 验证本机 default `env/infisical` profile 存在 +1. 验证当前浏览器登录会话可用 2. 扫描所有项目 `.env` 文件,问你"发现 N 个 key,要同步过去吗?" 3. 同步成功后才把 `one.manifest.json#domains.env.kind` 改成 `infisical` 4. 自动绑/建对应 Infisical project @@ -127,8 +127,8 @@ one env switch dotenv | `ENV_INVALID_KEY` | KEY 有非法字符 | 用 POSIX env-var 风格:`^[A-Z][A-Z0-9_]*$`(如 `DATABASE_URL`) | | `ENV_SET_KEY_REQUIRED` | `one env set` 没传 key | 用 `KEY=VALUE` 或 `KEY VALUE` | | `INFISICAL_NOT_CONFIGURED` | 工作区不是 `env/infisical`,或 manifest config 不完整 | 改 manifest 或重跑 `one create --env-provider infisical` | -| `INFISICAL_AUTH_MISSING` | 本机没 default `env/infisical` profile | 重跑 `one configure add env/infisical ... --use` | -| `INFISICAL_AUTH_FAILED` | client id / secret 错或过期 | Infisical 后台重新生成 secret,更新 profile | +| `INFISICAL_AUTH_MISSING` | 本机尚未登录 | 重跑 `one login` | +| `INFISICAL_AUTH_FAILED` | 会话失效或过期 | 退出后重新登录 | | `ENV_PULL_CONFLICT` | 本地 `.env` 跟 Infisical 不一致 | 看 diff,确认要覆盖加 `--force` | 完整码表:[错误码大全](/zh/docs/error-codes/)。 @@ -136,4 +136,4 @@ one env switch dotenv ## 下一步 - 多环境树、`.env.local` 覆盖、per-project path 覆盖 → [多环境变量](/zh/tutorials/env-multi-env/)(进阶) -- 不只 env,所有 `one configure` 的 backend → [管理 Profile](/zh/tutorials/configure-profiles/)(进阶) +- 共享凭据 → [浏览器登录](/zh/tutorials/infisical-login/)(进阶) diff --git a/apps/docs/content/tutorials/zh/first-workspace.mdx b/apps/docs/content/tutorials/zh/first-workspace.mdx index 774863d3..96a673f4 100644 --- a/apps/docs/content/tutorials/zh/first-workspace.mdx +++ b/apps/docs/content/tutorials/zh/first-workspace.mdx @@ -5,7 +5,7 @@ description: 直接在终端跑 one create。看清它往磁盘写了什么、 -这一章**只讲一条命令** — `one create`。挑模板是[上一章](/zh/tutorials/templates/);起好工作区后第一次部署是[下一章](/zh/tutorials/deploy/)。 +这一章介绍 `one create`。可先阅读[模板教程](/zh/tutorials/templates/)选择项目,再继续配置[环境变量](/zh/tutorials/env-vars/)。 ## 1. 装 One CLI diff --git a/apps/docs/content/tutorials/zh/infisical-login.mdx b/apps/docs/content/tutorials/zh/infisical-login.mdx new file mode 100644 index 00000000..cf6e29d1 --- /dev/null +++ b/apps/docs/content/tutorials/zh/infisical-login.mdx @@ -0,0 +1,56 @@ +--- +title: 登录并使用共享凭据 +description: 浏览器登录 Infisical、系统钥匙串与全局变量。 +--- + +## 浏览器登录 + +```bash +one login +one whoami +one logout +one login --site-url https://secrets.example.com +``` + +One 只保留一个 Infisical 账号。登录会打开浏览器,完成后把会话令牌存入系统钥匙串;不再要求 Client ID、Client Secret 或 Profile。更换账号或实例前先退出。系统钥匙串不可用时会报错,不回退到明文文件。会话过期后需显式重新登录;读取变量不会自动打开浏览器。 + +本机只保存全局变量的位置、界面语言和工作区记录等元数据。旧版凭据文件不再读取,也不会自动删除。 + +## 全局变量 + +在 Infisical 中准备一个已有项目和环境,然后选择存放位置: + +```bash +one env bind --global +one env bind --global --project-id PROJECT_ID --env dev +one env --global +one env list --global --env dev --path / +one env list --global --env dev --path /docker +one run --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_PASSWORD -- docker-push-script +``` + +列表返回当前层目录、变量名和说明,不返回值。执行时必须显式提供环境与目录;不会递归读取子目录、导入变量或展开跨目录引用。`--keys` 可进一步缩小注入范围。命令可在工作区之外使用,保留当前目录;普通 `one dev`、`one build` 和项目模式不会自动加载全局变量。 + +明文读取需要 `one env get KEY --global --env dev --path /docker --reveal`。写入可使用交互式密码输入,或 `one env set KEY --global --env dev --path /docker --stdin` 从标准输入读取;覆盖已有值需要 `--yes`。`one env unset KEY --global --env dev --path /docker` 删除远端变量。 + +## Dashboard + +运行 `one serve`。设置页管理登录、等待回调、取消登录、退出和语言;全局变量页管理存放项目、浏览环境与目录,以及增删改查变量。查看或复制时才读取明文,切换账号、环境、目录或离开页面会清除页面中的明文。远端变量操作即时生效;工作区绑定项目等 Manifest 修改先进入草稿,审阅后一次保存。 + +## 安全边界 + +变量注入和输出遮盖用于减少误泄露,不是同一系统账号下 Agent 的安全隔离。Agent 能执行任意程序时,仍可能读取或传出凭据;说明文字也是不可信数据。请在 Infisical 和云服务中限制账号权限、目录、环境及凭据有效期。One 对已知原始值做尽力输出遮盖,无法覆盖编码、变形或子进程写出的文件。Docker 等工具也可能自行保存登录凭据。 + +## 本机偏好与工作区工具 + +```bash +one locale zh-CN +one locale en-US +one locale auto +one init mise --dry-run +one init mise +one init hooks --dry-run +one init hooks +``` + +`one init mise` 生成工具配置,保留用户配置;`one init hooks` 配置 hk 检查和当前 checkout 的 Git 钩子。`one mise` 与 `one hk` 保持工具透传。语言偏好存入本机 preferences 文件。 diff --git a/apps/docs/content/tutorials/zh/json-output-error-codes.mdx b/apps/docs/content/tutorials/zh/json-output-error-codes.mdx index 77694d5d..e6142a74 100644 --- a/apps/docs/content/tutorials/zh/json-output-error-codes.mdx +++ b/apps/docs/content/tutorials/zh/json-output-error-codes.mdx @@ -43,7 +43,7 @@ one templates list -o text # 强制人类格式 } ``` -`schema` URL 在大版本内稳定;具体数据在对应 payload key 下(这里是 `templates`;`one add` 是 `projects`;`one configure list` 是 `profiles`)。 +`schema` URL 在大版本内稳定;具体数据在对应 payload key 下(这里是 `templates`;`one add` 是 `projects`;`one whoami` 是 `profiles`)。 ## 3. 错误包络 @@ -125,7 +125,7 @@ agent 可以直接根据命令的结构化输出使用这些恢复模式。 |---|---|---| | 工作区状态 | `NOT_ONE_PROJECT`、`WORKSPACE_NESTED_FORBIDDEN`、`MANIFEST_MISSING_OR_EMPTY` | "从工作区根跑"或"先 `one create`" | | 模板 | `TEMPLATE_NOT_FOUND`、`TEMPLATE_REQUIRED`、`INVALID_NAME` | 从 `error.context` 列出可用模板 | -| Backend | `BACKEND_NOT_ENABLED`、`PROFILE_NOT_FOUND`、`DOMAIN_INVALID`、`BACKEND_ID_UNKNOWN` | 指向 `one configure list ` | +| Backend | `BACKEND_NOT_ENABLED`、`PROFILE_NOT_FOUND`、`DOMAIN_INVALID`、`BACKEND_ID_UNKNOWN` | 指向 `one whoami` | | Infisical | `INFISICAL_NOT_CONFIGURED`、`INFISICAL_AUTH_MISSING`、`INFISICAL_AUTH_FAILED`、`ENV_PULL_CONFLICT` | 重新 auth 或加 `--force` 重跑 | | Container / deploy | `IMAGE_TAG_NOT_FOUND`、`REGISTRY_CREDENTIAL_MISSING`、`BUILD_VERSION_UNRESOLVED`、`VERCEL_DEPLOY_FAILED` | deploy 前先 build/push;修 profile | | Serve | `SERVE_PORT_BUSY`、`SERVE_BIND_FORBIDDEN` | 修改 host 或 port flag 后重启 | diff --git a/apps/docs/content/tutorials/zh/meta.json b/apps/docs/content/tutorials/zh/meta.json index 3acca8f7..299cfe01 100644 --- a/apps/docs/content/tutorials/zh/meta.json +++ b/apps/docs/content/tutorials/zh/meta.json @@ -4,14 +4,11 @@ "---基础教程---", "templates", "first-workspace", - "deploy", "---进阶教程---", - "configure-profiles", + "infisical-login", "env-vars", "env-multi-env", "dev-local", - "container-build-push", - "deploy-multi-backend", "json-output-error-codes" ] } diff --git a/apps/docs/package.json b/apps/docs/package.json index 4ea18355..b8831ccc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,30 +10,31 @@ "postinstall": "fumadocs-mdx" }, "dependencies": { - "@base-ui/react": "^1.4.1", + "@base-ui/react": "^1.8.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "fumadocs-core": "^15.0.0", - "fumadocs-mdx": "^11.0.0", - "fumadocs-ui": "^15.0.0", - "lucide-react": "^0.469.0", - "next": "^15.1.0", + "fumadocs-core": "^16.15.15", + "fumadocs-mdx": "^15.4.5", + "fumadocs-ui": "^16.15.15", + "lucide-react": "^1.48.0", + "next": "^16.3.6", "next-view-transitions": "^0.3.5", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "shadcn": "^4.7.0", - "tailwind-merge": "^3.5.0", - "three": "^0.184.0", - "tw-animate-css": "^1.4.0" + "react": "^19.3.0", + "react-dom": "^19.3.0", + "shadcn": "^4.21.0", + "tailwind-merge": "^3.7.0", + "three": "^0.186.1", + "tw-animate-css": "^1.4.0", + "zod": "^4.6.5" }, "devDependencies": { - "@tailwindcss/postcss": "^4.0.0", - "@types/node": "^22.10.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@types/three": "^0.184.1", - "postcss": "^8.5.0", - "tailwindcss": "^4.0.0", - "typescript": "^5.7.0" + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.6.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@types/three": "^0.186.0", + "postcss": "^8.5.28", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2" } } diff --git a/apps/docs/src/app/(home)/hero-canvas.tsx b/apps/docs/src/app/(home)/hero-canvas.tsx index d3165116..df88b88b 100644 --- a/apps/docs/src/app/(home)/hero-canvas.tsx +++ b/apps/docs/src/app/(home)/hero-canvas.tsx @@ -12,10 +12,10 @@ type HomeHeroCanvasProps = { ariaLabel?: string; lang?: Locale }; const copy = { zh: { - description: "One CLI 品牌双弧归一后生成工作区模块,展示前端、后端、文档、共享库、部署和 CLI 接口模块。", + description: "One CLI 品牌双弧归一后生成工作区模块,展示前端、后端、文档、共享库、构建和 CLI 接口模块。", }, en: { - description: "One CLI brand arcs reunite before the workspace assembles, showing frontend, backend, docs, library, deploy, and CLI interface modules.", + description: "One CLI brand arcs reunite before the workspace assembles, showing frontend, backend, docs, library, build, and CLI interface modules.", }, }; diff --git a/apps/docs/src/app/(home)/hero-workspace.ts b/apps/docs/src/app/(home)/hero-workspace.ts index 6646808c..a70a6696 100644 --- a/apps/docs/src/app/(home)/hero-workspace.ts +++ b/apps/docs/src/app/(home)/hero-workspace.ts @@ -21,7 +21,7 @@ export type ModuleId = | "docs" | "packages" | "env" - | "deploy" + | "build" | "manifest" | "cli-interface"; @@ -107,11 +107,11 @@ const moduleConfigs: ModuleConfig[] = [ delay: 360, }, { - id: "deploy", + id: "build", labels: { zh: "Deploy", en: "Deploy" }, spotlight: { - zh: { title: "Deploy", subtitle: "K8s / S3 / Vercel 部署" }, - en: { title: "Deploy", subtitle: "K8s, S3, Vercel deploy" }, + zh: { title: "Build", subtitle: "统一项目构建" }, + en: { title: "Build", subtitle: "Build workspace projects" }, }, x: 1.0, y: 0.48, diff --git a/apps/docs/src/app/(home)/home-page.tsx b/apps/docs/src/app/(home)/home-page.tsx index 3cfa0a41..988e2dbd 100644 --- a/apps/docs/src/app/(home)/home-page.tsx +++ b/apps/docs/src/app/(home)/home-page.tsx @@ -6,7 +6,6 @@ import { ClipboardCheck, Code2, FileJson2, - Github, Layers3, PackageCheck, Route, @@ -14,6 +13,7 @@ import { Sparkles, Wrench, } from "lucide-react"; +import { GithubIcon as Github } from "@/components/github-icon"; import { defaultLocale, localeLabels, @@ -141,9 +141,9 @@ const homeCopy = { 也能继续加。 ), - body: "one add 只在已有 One 项目里使用。你可以直接输入 one add 进入交互式选择,也可以在脚本里写明模板名、项目名和部署方式。", + body: "one add 只在已有 One 项目里使用。你可以直接输入 one add 进入交互式选择,也可以在脚本里写明模板名和项目名。", bullets: [ - ["交互添加", "直接输入 one add,会让你选择模板、项目名和可选部署方式。"], + ["交互添加", "直接输入 one add,会让你选择模板和项目名。"], ["自动化模式", "CI 或 AI 才需要写 one add nextjs-app --name web --yes。"], ["同步默认值", "按模板生成项目代码,并登记本地开发命令。"], ], @@ -167,19 +167,19 @@ const homeCopy = { 放在安全位置。 ), - body: "one configure 保存本机要用的环境、部署和镜像账号。直接运行会进入配置向导;脚本里才需要写 env/infisical、deploy/*、container/docker 这些完整路径。", + body: "one configure 保存本机的 Infisical 凭据和偏好设置。直接运行会进入配置向导;脚本里使用 env/infisical 路径。", bullets: [ ["本机保存", "配置写到 ~/.config/one;密钥文件只有本人可读。"], ["远程环境", "远程 env 指 Infisical 云服务或你自己的 Infisical;不需要额外启动 One CLI 服务。"], - ["后续复用", "one env、one run、one deploy 会自动读取当前配置。"], + ["后续复用", "one env、one run 会自动读取当前配置。"], ], href: ["cli-overview"], cta: "查看 CLI 参考", sample: "one configure", output: [ - "打开 env / deploy / container 配置向导", + "打开 Infisical 配置向导", "保存本机配置档和凭据", - "供 one env、one run、one deploy 后续读取", + "供 one env、one run 后续读取", ], }, { @@ -380,7 +380,7 @@ const homeCopy = { meta: { title: "One CLI | From product idea to launch-ready project", description: - "One CLI gives AI a real product foundation: website, backend, docs, environment config, and deployment flow.", + "One CLI gives AI a real product foundation: website, backend, docs, environment config, and build commands.", }, nav: { docs: "Docs", @@ -403,9 +403,9 @@ const homeCopy = { Start with one command. ), - body: "Give AI a real product foundation: website, backend, docs, environment config, and deployment flow, ready from day one.", + body: "Give AI a real product foundation: website, backend, docs, environment config, and build commands, ready from day one.", canvasAria: - "One CLI workspace module canvas showing apps, API, docs, packages, manifest, env, deploy, and CLI interface modules", + "One CLI workspace module canvas showing apps, API, docs, packages, manifest, env, build, and CLI interface modules", install: "Start building", github: "View on GitHub", copy: "copy", @@ -482,9 +482,9 @@ const homeCopy = { after the project exists. ), - body: "one add is for an existing One project. You can run one add directly to use the picker, or pass the template name, project name, and deploy option in scripts.", + body: "one add is for an existing One project. You can run one add directly to use the picker, or pass the template name and project name in scripts.", bullets: [ - ["PROMPTED ADD", "Run one add to choose the template, project name, and optional deploy backend."], + ["PROMPTED ADD", "Run one add to choose the template and project name."], ["AUTOMATION", "CI and AI use one add nextjs-app --name web --yes."], ["SYNC DEFAULTS", "Templates generate project code and register local development commands."], ], @@ -508,19 +508,19 @@ const homeCopy = { in the right place. ), - body: "one configure saves the env, deploy, and container accounts this machine can use. Run it directly for the wizard; scripts use full paths such as env/infisical, deploy/*, or container/docker.", + body: "one configure saves this machine's Infisical credentials and preferences. Run it directly for the wizard; scripts use the env/infisical path.", bullets: [ ["LOCAL FILES", "Writes under ~/.config/one; secret files are readable only by you."], ["REMOTE ENV", "Remote env means Infisical Cloud or your own Infisical. You do not run a separate One CLI service."], - ["REUSED LATER", "one env, one run, and one deploy read the default profile automatically."], + ["REUSED LATER", "one env and one run read the default profile automatically."], ], href: ["cli-overview"], cta: "Explore CLI reference", sample: "one configure", output: [ - "open env / deploy / container profile prompts", + "open Infisical profile prompts", "save local profile and credential files", - "feed later one env, one run, and one deploy commands", + "feed later one env and one run commands", ], }, { diff --git a/apps/docs/src/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/src/app/[lang]/docs/[[...slug]]/page.tsx index 9a1bbca1..66f0a89c 100644 --- a/apps/docs/src/app/[lang]/docs/[[...slug]]/page.tsx +++ b/apps/docs/src/app/[lang]/docs/[[...slug]]/page.tsx @@ -38,7 +38,7 @@ export default async function Page(props: { title: string; description?: string; body: React.ComponentType; - toc?: import("fumadocs-core/server").TableOfContents; + toc?: import("fumadocs-core/toc").TableOfContents; full?: boolean; }; const MDX = data.body; @@ -61,8 +61,8 @@ export default async function Page(props: { /> ), }} - container={{ className: "one-docs-page" }} - article={{ id: "nd-page_article", className: "one-docs-article" }} + id="nd-page_article" + className="one-docs-page one-docs-article" tableOfContent={{ component: , }} diff --git a/apps/docs/src/app/[lang]/docs/layout.tsx b/apps/docs/src/app/[lang]/docs/layout.tsx index 9d1edc39..d0589712 100644 --- a/apps/docs/src/app/[lang]/docs/layout.tsx +++ b/apps/docs/src/app/[lang]/docs/layout.tsx @@ -1,4 +1,4 @@ -import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { DocsLayout } from "../../docs/docs-layout"; import type { ReactNode } from "react"; import { SiteTopNav } from "@/components/site-top-nav"; import { isLocale } from "@/i18n"; @@ -31,9 +31,9 @@ export default async function Layout({ "one-docs-layout md:[--fd-nav-height:64px] md:[--fd-sidebar-width:280px] xl:[--fd-toc-width:260px] xl:[--fd-page-width:1160px]", }} sidebar={{ - component: , - tabs: false, + children: , }} + tabs={false} searchToggle={{ enabled: false }} > {children} diff --git a/apps/docs/src/app/[lang]/tutorials/[[...slug]]/page.tsx b/apps/docs/src/app/[lang]/tutorials/[[...slug]]/page.tsx index 0ae045d2..3252371e 100644 --- a/apps/docs/src/app/[lang]/tutorials/[[...slug]]/page.tsx +++ b/apps/docs/src/app/[lang]/tutorials/[[...slug]]/page.tsx @@ -39,7 +39,7 @@ export default async function Page(props: { body: React.ComponentType<{ components?: Record>; }>; - toc?: import("fumadocs-core/server").TableOfContents; + toc?: import("fumadocs-core/toc").TableOfContents; full?: boolean; kind?: string; }; @@ -65,8 +65,8 @@ export default async function Page(props: { /> ), }} - container={{ className: "one-docs-page" }} - article={{ id: "nd-page_article", className: "one-docs-article" }} + id="nd-page_article" + className="one-docs-page one-docs-article" tableOfContent={{ component: , }} diff --git a/apps/docs/src/app/[lang]/tutorials/layout.tsx b/apps/docs/src/app/[lang]/tutorials/layout.tsx index 23c9a925..6af39cae 100644 --- a/apps/docs/src/app/[lang]/tutorials/layout.tsx +++ b/apps/docs/src/app/[lang]/tutorials/layout.tsx @@ -1,4 +1,4 @@ -import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { DocsLayout } from "../../docs/docs-layout"; import type { ReactNode } from "react"; import { SiteTopNav } from "@/components/site-top-nav"; import { isLocale } from "@/i18n"; @@ -31,9 +31,9 @@ export default async function Layout({ "one-docs-layout md:[--fd-nav-height:64px] md:[--fd-sidebar-width:280px] xl:[--fd-toc-width:260px] xl:[--fd-page-width:1160px]", }} sidebar={{ - component: , - tabs: false, + children: , }} + tabs={false} searchToggle={{ enabled: false }} > {children} diff --git a/apps/docs/src/app/docs/docs-layout.tsx b/apps/docs/src/app/docs/docs-layout.tsx new file mode 100644 index 00000000..618757e8 --- /dev/null +++ b/apps/docs/src/app/docs/docs-layout.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { + DocsLayout as FumadocsLayout, + type DocsLayoutProps, + type DocsSlots, +} from "fumadocs-ui/layouts/docs"; +import { + SidebarProvider, + SidebarTrigger, + useSidebar, + type SidebarProps, +} from "fumadocs-ui/layouts/docs/slots/sidebar"; + +function CustomSidebar({ children }: SidebarProps) { + return children; +} + +const sidebarSlot: DocsSlots["sidebar"] = { + provider: SidebarProvider, + root: CustomSidebar, + trigger: SidebarTrigger, + useSidebar, +}; + +export function DocsLayout(props: DocsLayoutProps) { + return ; +} diff --git a/apps/docs/src/app/docs/docs-sidebar-shell.tsx b/apps/docs/src/app/docs/docs-sidebar-shell.tsx new file mode 100644 index 00000000..6d2c9c11 --- /dev/null +++ b/apps/docs/src/app/docs/docs-sidebar-shell.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { + SidebarContent, + SidebarDrawerContent, + SidebarDrawerOverlay, +} from "fumadocs-ui/components/sidebar/base"; +import type { ReactNode } from "react"; + +export function DocsSidebarShell({ children }: { children: ReactNode }) { + return ( + <> + + {({ ref }) => ( + + )} + + + + {children} + + + ); +} diff --git a/apps/docs/src/app/docs/docs-sidebar.tsx b/apps/docs/src/app/docs/docs-sidebar.tsx index eb42c88f..7fc9d7af 100644 --- a/apps/docs/src/app/docs/docs-sidebar.tsx +++ b/apps/docs/src/app/docs/docs-sidebar.tsx @@ -1,10 +1,6 @@ "use client"; -import { - Sidebar, - SidebarContent, - SidebarContentMobile, -} from "fumadocs-ui/components/layout/sidebar"; +import { DocsSidebarShell } from "./docs-sidebar-shell"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { localizedDocsPath, type Locale } from "@/i18n"; @@ -54,13 +50,7 @@ const sectionsByLocale: Record = { href: "/docs/templates-cmd/", mono: true, }, - { - label: "one container", - href: "/docs/container/", - mono: true, - }, { label: "one dev", href: "/docs/dev/", mono: true }, - { label: "one deploy", href: "/docs/deploy/", mono: true }, { label: "one run", href: "/docs/run/", mono: true }, { label: "one serve", href: "/docs/serve/", mono: true }, { label: "错误码", href: "/docs/error-codes/" }, @@ -100,13 +90,7 @@ const sectionsByLocale: Record = { href: "/docs/templates-cmd/", mono: true, }, - { - label: "one container", - href: "/docs/container/", - mono: true, - }, { label: "one dev", href: "/docs/dev/", mono: true }, - { label: "one deploy", href: "/docs/deploy/", mono: true }, { label: "one run", href: "/docs/run/", mono: true }, { label: "one serve", href: "/docs/serve/", mono: true }, { @@ -134,21 +118,10 @@ const sidebarText: Record< }; export function DocsSidebar({ lang }: { lang: Locale }) { - const sidebar = ; - return ( - - {sidebar} - - } - Mobile={ - - {sidebar} - - } - /> + + + ); } diff --git a/apps/docs/src/app/docs/docs-toc.tsx b/apps/docs/src/app/docs/docs-toc.tsx index 5041b007..002f171a 100644 --- a/apps/docs/src/app/docs/docs-toc.tsx +++ b/apps/docs/src/app/docs/docs-toc.tsx @@ -2,7 +2,6 @@ import { TOCItem, type TOCItemType, useActiveAnchor } from "fumadocs-core/toc"; import { MessageSquare, Pencil } from "lucide-react"; -import { PageTOC } from "fumadocs-ui/layouts/docs/page"; import type { Locale } from "@/i18n"; type DocsTocProps = { @@ -17,49 +16,55 @@ export function DocsToc({ docPath, items, lang }: DocsTocProps) { const labels = tocLabels[lang]; return ( - -

{labels.heading}

-
- {items.map((item) => { - const active = - item.url === `#${activeAnchor}` || - (activeAnchor === undefined && item.url === fallbackActive); + ); } diff --git a/apps/docs/src/app/docs/tutorials-sidebar.tsx b/apps/docs/src/app/docs/tutorials-sidebar.tsx index 67228e69..2ab21c1c 100644 --- a/apps/docs/src/app/docs/tutorials-sidebar.tsx +++ b/apps/docs/src/app/docs/tutorials-sidebar.tsx @@ -1,10 +1,6 @@ "use client"; -import { - Sidebar, - SidebarContent, - SidebarContentMobile, -} from "fumadocs-ui/components/layout/sidebar"; +import { DocsSidebarShell } from "./docs-sidebar-shell"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { localizedTutorialsPath, type Locale } from "@/i18n"; @@ -26,7 +22,6 @@ const sectionsByLocale: Record = { items: [ { label: "一键创建工作区", href: "/tutorials/templates/" }, { label: "手动创建工作区", href: "/tutorials/first-workspace/" }, - { label: "一键部署", href: "/tutorials/deploy/" }, ], }, { @@ -36,11 +31,6 @@ const sectionsByLocale: Record = { { label: "配置环境变量", href: "/tutorials/env-vars/" }, { label: "多环境变量", href: "/tutorials/env-multi-env/" }, { label: "本地开发编排", href: "/tutorials/dev-local/" }, - { - label: "构建与推送镜像", - href: "/tutorials/container-build-push/", - }, - { label: "服务部署", href: "/tutorials/deploy-multi-backend/" }, { label: "输出与错误码", href: "/tutorials/json-output-error-codes/", @@ -54,7 +44,6 @@ const sectionsByLocale: Record = { items: [ { label: "One-click workspace", href: "/tutorials/templates/" }, { label: "Manual workspace", href: "/tutorials/first-workspace/" }, - { label: "One-click deploy", href: "/tutorials/deploy/" }, ], }, { @@ -67,11 +56,6 @@ const sectionsByLocale: Record = { { label: "Configure env vars", href: "/tutorials/env-vars/" }, { label: "Multi-env vars", href: "/tutorials/env-multi-env/" }, { label: "Local dev orchestration", href: "/tutorials/dev-local/" }, - { - label: "Build & push images", - href: "/tutorials/container-build-push/", - }, - { label: "Service deploy", href: "/tutorials/deploy-multi-backend/" }, { label: "Output & error codes", href: "/tutorials/json-output-error-codes/", @@ -96,21 +80,10 @@ const sidebarText: Record< }; export function TutorialsSidebar({ lang }: { lang: Locale }) { - const sidebar = ; - return ( - - {sidebar} - - } - Mobile={ - - {sidebar} - - } - /> + + + ); } diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index ac3fae8b..b6f630b6 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -585,6 +585,13 @@ article blockquote { background: var(--surface-primary); } +@media (min-width: 768px) { + .one-docs-layout { + --fd-banner-height: 64px; + padding-top: 64px; + } +} + .one-docs-sidebar-shell { align-items: stretch !important; background: var(--surface-primary) !important; @@ -1321,10 +1328,6 @@ article blockquote { } @media (max-width: 1279px) { - .one-docs-layout { - --fd-sidebar-width: 256px; - } - .one-docs-topbar { padding: 0 32px; } @@ -1333,6 +1336,12 @@ article blockquote { padding-right: 26px; padding-left: 32px; } +} + +@media (min-width: 768px) and (max-width: 1279px) { + .one-docs-layout { + --fd-sidebar-width: 256px; + } .one-docs-article { padding-right: 54px; diff --git a/apps/docs/src/app/layout.tsx b/apps/docs/src/app/layout.tsx index 85cac11e..1cd76f60 100644 --- a/apps/docs/src/app/layout.tsx +++ b/apps/docs/src/app/layout.tsx @@ -1,5 +1,5 @@ import "./global.css"; -import { RootProvider } from "fumadocs-ui/provider"; +import { RootProvider } from "fumadocs-ui/provider/next"; import { ViewTransitions } from "next-view-transitions"; import Script from "next/script"; import type { ReactNode } from "react"; diff --git a/apps/docs/src/components/custom-template-modal.tsx b/apps/docs/src/components/custom-template-modal.tsx index 5b72bcdc..096351ce 100644 --- a/apps/docs/src/components/custom-template-modal.tsx +++ b/apps/docs/src/components/custom-template-modal.tsx @@ -20,7 +20,6 @@ import { } from "@/components/ui/dialog"; import { templates, - type DeployOption, type TemplateKind, type TemplateMeta, } from "@/data/templates"; @@ -32,14 +31,11 @@ import { type ModalLocale = "zh" | "en"; type EnvProvider = "dotenv" | "infisical"; type KindFilter = "all" | TemplateKind; -type ContainerKind = "docker" | "dockerhub" | "ghcr" | "acr"; type Selection = { uid: string; templateId: string; name: string; - deployCode: string | null; - containerCode: string | null; }; const kindOrder: KindFilter[] = ["all", "frontend", "backend", "library"]; @@ -51,26 +47,11 @@ const kindIcons: Record = { library: Library, }; -const containerOptions: { - id: ContainerKind; - code: string; - name: Record; -}[] = [ - { id: "dockerhub", code: "h", name: { zh: "Docker Hub", en: "Docker Hub" } }, - { id: "ghcr", code: "g", name: { zh: "GHCR", en: "GHCR" } }, - { id: "acr", code: "a", name: { zh: "阿里云 ACR", en: "Aliyun ACR" } }, - { - id: "docker", - code: "d", - name: { zh: "通用 Docker Registry", en: "Generic Docker Registry" }, - }, -]; - const copy = { zh: { title: "自定义模板", subtitle: - "选择需要的模板与部署目标,右侧会实时生成单条 one create 命令。", + "选择需要的模板与环境来源,右侧会实时生成单条 one create 命令。", close: "关闭", kinds: { all: "全部", @@ -88,17 +69,11 @@ const copy = { copy: "复制", copied: "已复制", nameHelp: "决定 workspace 目录;子项目名可在下方分别设置", - deployModal: { - title: "选择部署目标", - containerTitle: "Container 类型", - cancel: "取消", - confirm: "添加", - }, }, en: { title: "Build your own", subtitle: - "Pick templates and deploy targets; a single one create command appears live on the right.", + "Pick templates and an environment source; a single one create command appears live on the right.", close: "Close", kinds: { all: "All", @@ -116,12 +91,6 @@ const copy = { copy: "Copy", copied: "Copied", nameHelp: "Used for the workspace directory; subprojects can be named below", - deployModal: { - title: "Choose a deploy target", - containerTitle: "Container type", - cancel: "Cancel", - confirm: "Add", - }, }, } satisfies Record; @@ -140,9 +109,6 @@ export function CustomTemplateModal({ const [workspaceName, setWorkspaceName] = useState("my-workspace"); const [env, setEnv] = useState("dotenv"); const [copied, setCopied] = useState(false); - const [pendingTemplate, setPendingTemplate] = useState( - null, - ); const visibleTemplates = useMemo(() => { if (activeKind === "all") return templates; @@ -158,8 +124,6 @@ export function CustomTemplateModal({ uid: s.uid, kind: t.presetKind, tcode: t.code, - dcode: s.deployCode ?? undefined, - ccode: s.containerCode ?? undefined, templateId: t.id, title: t.title, defaultName: t.defaultName, @@ -174,17 +138,11 @@ export function CustomTemplateModal({ }, [selection, env, workspaceName]); function attemptAdd(template: TemplateMeta) { - if (template.deployOptions.length === 0) { - addToSelection(template, null, null); - return; - } - setPendingTemplate(template); + addToSelection(template); } function addToSelection( template: TemplateMeta, - deployCode: string | null, - containerCode: string | null, ) { setSelection((prev) => [ ...prev, @@ -192,8 +150,6 @@ export function CustomTemplateModal({ uid: `${template.id}-${Date.now()}-${prev.length}`, templateId: template.id, name: nextProjectName(template, prev), - deployCode, - containerCode, }, ]); } @@ -382,16 +338,6 @@ export function CustomTemplateModal({ {selection.map((s) => { const t = templates.find((x) => x.id === s.templateId); if (!t) return null; - const deployName = s.deployCode - ? t.deployOptions.find( - (o) => o.code === s.deployCode, - )?.name[lang] ?? s.deployCode - : null; - const containerName = s.containerCode - ? containerOptions.find( - (o) => o.code === s.containerCode, - )?.name[lang] ?? s.containerCode - : null; return (
  • - {deployName && ( -
    - - {deployName} -
    - )} - {containerName && ( -
    - {containerName} -
    - )} + +
  • - setPendingTemplate(null)} - onConfirm={({ deployCode, containerCode }) => { - if (pendingTemplate) { - addToSelection(pendingTemplate, deployCode, containerCode); - } - setPendingTemplate(null); - }} - /> + ); } @@ -579,206 +505,6 @@ function TemplateChip({ ); } -function DeployPickerDialog({ - lang, - template, - text, - onCancel, - onConfirm, -}: { - lang: ModalLocale; - template: TemplateMeta | null; - text: (typeof copy)[ModalLocale]; - onCancel: () => void; - onConfirm: (value: { - deployCode: string | null; - containerCode: string | null; - }) => void; -}) { - const [chosen, setChosen] = useState(null); - const [container, setContainer] = useState("h"); - - // Initialize chosen when template changes. - useMemoChosenInit(template, setChosen, setContainer); - - function handleOpenChange(nextOpen: boolean) { - if (nextOpen) return; - onCancel(); - } - - return ( - - - {template && ( - <> -
    -
    -
    - - - {text.deployModal.title} - -
    -

    - {template.title[lang]} -

    -
    - -
    - -
      - {template.deployOptions.map((opt) => ( - setChosen(opt.code)} - /> - ))} -
    - - {chosen === "k" && ( -
    -

    - {text.deployModal.containerTitle} -

    -
    - {containerOptions.map((opt) => ( - - ))} -
    -
    - )} - -
    - - -
    - - )} -
    -
    - ); -} - -function useMemoChosenInit( - template: TemplateMeta | null, - setChosen: (v: string | null) => void, - setContainer: (v: string) => void, -) { - // Reset chosen when template opens; runs only when template identity changes. - useMemo(() => { - if (template) { - setChosen(template.defaultDeployCode); - setContainer("h"); - } - }, [template]); -} - -function DeployOptionRow({ - option, - lang, - isDefault, - checked, - onChoose, -}: { - option: DeployOption; - lang: ModalLocale; - isDefault: boolean; - checked: boolean; - onChoose: () => void; -}) { - return ( -
  • - -
  • - ); -} - function nextProjectName(template: TemplateMeta, selection: Selection[]) { const taken = selection.map((s) => s.name); if (!taken.includes(template.defaultName)) return template.defaultName; diff --git a/apps/docs/src/components/github-icon.tsx b/apps/docs/src/components/github-icon.tsx new file mode 100644 index 00000000..d8e24527 --- /dev/null +++ b/apps/docs/src/components/github-icon.tsx @@ -0,0 +1,39 @@ +import type { SVGProps } from "react"; + +/* + * GitHub icon from Lucide 0.469.0, retained after brand icons were removed. + * ISC License + * Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as + * part of Feather (MIT). All other copyright (c) for Lucide are held by + * Lucide Contributors 2022. + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ +export function GithubIcon(props: SVGProps) { + return ( + + ); +} diff --git a/apps/docs/src/components/preset-create-command-dialog.tsx b/apps/docs/src/components/preset-create-command-dialog.tsx index 7639e323..743e326e 100644 --- a/apps/docs/src/components/preset-create-command-dialog.tsx +++ b/apps/docs/src/components/preset-create-command-dialog.tsx @@ -17,7 +17,6 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import type { Example } from "@/data/examples"; import { templates, - type DeployOption, type TemplateKind, type TemplateMeta, } from "@/data/templates"; @@ -31,7 +30,6 @@ import { type DialogLocale = "zh" | "en"; type KindFilter = "all" | TemplateKind; -type ContainerKind = "docker" | "dockerhub" | "ghcr" | "acr"; const kindOrder: KindFilter[] = ["all", "frontend", "backend", "library"]; @@ -42,21 +40,6 @@ const kindIcons: Record = { library: Library, }; -const containerOptions: { - id: ContainerKind; - code: string; - name: Record; -}[] = [ - { id: "dockerhub", code: "h", name: { zh: "Docker Hub", en: "Docker Hub" } }, - { id: "ghcr", code: "g", name: { zh: "GHCR", en: "GHCR" } }, - { id: "acr", code: "a", name: { zh: "阿里云 ACR", en: "Aliyun ACR" } }, - { - id: "docker", - code: "d", - name: { zh: "通用 Docker Registry", en: "Generic Docker Registry" }, - }, -]; - const copy = { zh: { title: "复制创建命令", @@ -78,12 +61,6 @@ const copy = { backend: "后端", library: "库", } satisfies Record, - deployModal: { - title: "选择部署目标", - containerTitle: "Container 类型", - cancel: "取消", - confirm: "添加", - }, }, en: { title: "Copy create command", @@ -105,12 +82,6 @@ const copy = { backend: "Backend", library: "Library", } satisfies Record, - deployModal: { - title: "Choose a deploy target", - containerTitle: "Container type", - cancel: "Cancel", - confirm: "Add", - }, }, } satisfies Record; @@ -129,9 +100,6 @@ export function PresetCreateCommandDialog({ const [workspaceName, setWorkspaceName] = useState("my-workspace"); const [projects, setProjects] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); - const [pendingTemplate, setPendingTemplate] = useState( - null, - ); const [copied, setCopied] = useState(false); useEffect(() => { @@ -144,7 +112,6 @@ export function PresetCreateCommandDialog({ })), ); setPickerOpen(false); - setPendingTemplate(null); setCopied(false); }, [example, open]); @@ -187,17 +154,11 @@ export function PresetCreateCommandDialog({ } function attemptAdd(template: TemplateMeta) { - if (template.deployOptions.length === 0) { - addProject(template, null, null); - return; - } - setPendingTemplate(template); + addProject(template); } function addProject( template: TemplateMeta, - deployCode: string | null, - containerCode: string | null, ) { setProjects((prev) => [ ...prev, @@ -205,8 +166,6 @@ export function PresetCreateCommandDialog({ uid: `${template.id}-${Date.now()}-${prev.length}`, kind: template.presetKind, tcode: template.code, - dcode: deployCode ?? undefined, - ccode: containerCode ?? undefined, templateId: template.id, title: template.title, defaultName: template.defaultName, @@ -358,18 +317,7 @@ export function PresetCreateCommandDialog({ }} /> - setPendingTemplate(null)} - onConfirm={({ deployCode, containerCode }) => { - if (pendingTemplate) { - addProject(pendingTemplate, deployCode, containerCode); - } - setPendingTemplate(null); - }} - /> + ); } @@ -472,186 +420,6 @@ function AddProjectDialog({ ); } -function DeployPickerDialog({ - lang, - template, - text, - onCancel, - onConfirm, -}: { - lang: DialogLocale; - template: TemplateMeta | null; - text: (typeof copy)[DialogLocale]; - onCancel: () => void; - onConfirm: (value: { - deployCode: string | null; - containerCode: string | null; - }) => void; -}) { - const [chosen, setChosen] = useState(null); - const [container, setContainer] = useState("h"); - - useEffect(() => { - if (!template) return; - setChosen(template.defaultDeployCode); - setContainer("h"); - }, [template]); - - return ( - !nextOpen && onCancel()}> - - {template && ( - <> -
    -
    -
    - - - {text.deployModal.title} - -
    -

    - {template.title[lang]} -

    -
    - -
    - -
      - {template.deployOptions.map((opt) => ( - setChosen(opt.code)} - /> - ))} -
    - - {chosen === "k" && ( -
    -

    - {text.deployModal.containerTitle} -

    -
    - {containerOptions.map((opt) => ( - - ))} -
    -
    - )} - -
    - - -
    - - )} -
    -
    - ); -} - -function DeployOptionRow({ - option, - lang, - isDefault, - checked, - onChoose, -}: { - option: DeployOption; - lang: DialogLocale; - isDefault: boolean; - checked: boolean; - onChoose: () => void; -}) { - return ( -
  • - -
  • - ); -} - function nextProjectName(template: TemplateMeta, projects: CommandProject[]) { const taken = projects.map((project) => project.name); if (!taken.includes(template.defaultName)) return template.defaultName; diff --git a/apps/docs/src/components/site-top-nav.tsx b/apps/docs/src/components/site-top-nav.tsx index 9da23bfa..c472e902 100644 --- a/apps/docs/src/components/site-top-nav.tsx +++ b/apps/docs/src/components/site-top-nav.tsx @@ -1,4 +1,5 @@ -import { Github, Search } from "lucide-react"; +import { Search } from "lucide-react"; +import { GithubIcon as Github } from "@/components/github-icon"; import Link from "next/link"; import { BrandMark } from "@/components/brand-mark"; import { diff --git a/apps/docs/src/components/template-example-detail.tsx b/apps/docs/src/components/template-example-detail.tsx index d90dfadc..afd527ed 100644 --- a/apps/docs/src/components/template-example-detail.tsx +++ b/apps/docs/src/components/template-example-detail.tsx @@ -55,7 +55,7 @@ const copy = { templates: "包含模板", customizeCard: { title: "想自由组合?", - body: "打开自定义模板,挑选项目 / 部署 / env,命令实时生成。", + body: "打开自定义模板,挑选项目 / env,命令实时生成。", cta: "自定义模板", }, }, @@ -67,7 +67,7 @@ const copy = { templates: "Templates included", customizeCard: { title: "Want a custom mix?", - body: "Open the builder to pick projects, deploy targets and env. Commands update live.", + body: "Open the builder to pick projects and env. Commands update live.", cta: "Build your own", }, }, diff --git a/apps/docs/src/components/template-examples-list.tsx b/apps/docs/src/components/template-examples-list.tsx index 6dd88a68..9e30c2ff 100644 --- a/apps/docs/src/components/template-examples-list.tsx +++ b/apps/docs/src/components/template-examples-list.tsx @@ -51,7 +51,7 @@ const copyText = { zh: { eyebrow: "TEMPLATE EXAMPLES", title: "从一个完整的起步套件开始。", - body: "每个示例都是一个完整的 One CLI workspace 配置(项目 + 部署 + env)。直接复制创建命令、或点开查看默认页面与可粘贴 prompt。", + body: "每个示例都是一个完整的 One CLI workspace 配置(项目 + env)。直接复制创建命令、或点开查看默认页面与可粘贴 prompt。", customizeBtn: "自定义模板", filterAll: "全部", countSuffix: "个示例", @@ -68,12 +68,12 @@ const copyText = { } satisfies Record, notFoundTitle: "找不到完全匹配的示例?", notFoundBody: - "用“自定义模板”自由组合项目、部署目标和 env,命令实时生成。", + "用“自定义模板”自由组合项目和 env,命令实时生成。", }, en: { eyebrow: "TEMPLATE EXAMPLES", title: "Start from a complete starter kit.", - body: "Each example is a full One CLI workspace (projects + deploy + env). Copy the create command directly, or open it to see the default pages and a paste-ready prompt.", + body: "Each example is a full One CLI workspace (projects + env). Copy the create command directly, or open it to see the default pages and a paste-ready prompt.", customizeBtn: "Build your own", filterAll: "All", countSuffix: "examples", @@ -90,7 +90,7 @@ const copyText = { } satisfies Record, notFoundTitle: "Don't see a perfect match?", notFoundBody: - "Use “Build your own” to compose projects, deploy targets and env. Commands update live.", + "Use “Build your own” to compose projects and env. Commands update live.", }, } satisfies Record; diff --git a/apps/docs/src/components/template-examples-nav.tsx b/apps/docs/src/components/template-examples-nav.tsx index 042887d1..c8b04c8f 100644 --- a/apps/docs/src/components/template-examples-nav.tsx +++ b/apps/docs/src/components/template-examples-nav.tsx @@ -1,6 +1,7 @@ "use client"; -import { Github, Menu } from "lucide-react"; +import { Menu } from "lucide-react"; +import { GithubIcon as Github } from "@/components/github-icon"; import Link from "next/link"; import { BrandMark } from "@/components/brand-mark"; import { localeLabels, locales, type Locale } from "@/i18n"; diff --git a/apps/docs/src/data/examples.ts b/apps/docs/src/data/examples.ts index bbf3c392..dc160054 100644 --- a/apps/docs/src/data/examples.ts +++ b/apps/docs/src/data/examples.ts @@ -67,16 +67,16 @@ export const examples: Example[] = [ en: "Expo + React Native + NestJS API, for mobile apps that need a backend", }, baseTemplates: ["expo-mobile", "nestjs-api"], - presetId: "1.bnekh.fem.ed", + presetId: "1.bne.fem.ed", workspaceName: "mobile-app", cover: "/examples/mobile-starter/cover.png", prompt: makePrompt({ titleZh: "移动端起步套件", titleEn: "the Mobile Starter", workspaceName: "mobile-app", - presetId: "1.bnekh.fem.ed", - stackZh: "Expo App + NestJS API + Kustomize + Docker Hub + dotenv", - stackEn: "Expo app + NestJS API + Kustomize + Docker Hub + dotenv", + presetId: "1.bne.fem.ed", + stackZh: "Expo App + NestJS API + dotenv", + stackEn: "Expo app + NestJS API + dotenv", }), tags: ["expo", "react-native", "nestjs", "mobile"], }, @@ -111,16 +111,16 @@ export const examples: Example[] = [ en: "Astro static site + NestJS API for forms, ideal for marketing sites that need a backend", }, baseTemplates: ["astro-site", "nestjs-api"], - presetId: "1.bnekh.fasc.ed", + presetId: "1.bne.fas.ed", workspaceName: "marketing-site", cover: "/examples/landing-starter/cover.png", prompt: makePrompt({ titleZh: "营销落地页", titleEn: "the Marketing Landing starter", workspaceName: "marketing-site", - presetId: "1.bnekh.fasc.ed", - stackZh: "Astro 站点 + Cloudflare + NestJS API + Kustomize + Docker Hub + dotenv", - stackEn: "Astro site + Cloudflare + NestJS API + Kustomize + Docker Hub + dotenv", + presetId: "1.bne.fas.ed", + stackZh: "Astro 站点 + NestJS API + dotenv", + stackEn: "Astro site + NestJS API + dotenv", }), tags: ["astro", "nestjs", "cloudflare", "marketing"], }, @@ -133,16 +133,16 @@ export const examples: Example[] = [ en: "Astro Starlight with built-in search, sidebar, and reference tables", }, baseTemplates: ["starlight-docs"], - presetId: "1.fslc.ed", + presetId: "1.fsl.ed", workspaceName: "docs-site", cover: "/examples/docs-starter/cover.png", prompt: makePrompt({ titleZh: "文档站", titleEn: "the Documentation Site starter", workspaceName: "docs-site", - presetId: "1.fslc.ed", - stackZh: "Starlight + Cloudflare + dotenv", - stackEn: "Starlight + Cloudflare + dotenv", + presetId: "1.fsl.ed", + stackZh: "Starlight + dotenv", + stackEn: "Starlight + dotenv", }), tags: ["starlight", "astro", "docs"], }, @@ -151,20 +151,20 @@ export const examples: Example[] = [ category: "consumer", title: { zh: "C 端 Web", en: "Consumer Web" }, tagline: { - zh: "Next.js App Router + SSR,部署到 Vercel,适合 SEO / 首屏要求高的 C 端内容站", - en: "Next.js App Router + SSR on Vercel, for consumer sites where SEO and first paint matter", + zh: "Next.js App Router + SSR,适合 SEO / 首屏要求高的 C 端内容站", + en: "Next.js App Router + SSR, for consumer sites where SEO and first paint matter", }, baseTemplates: ["nextjs-app"], - presetId: "1.fnav.ed", + presetId: "1.fna.ed", workspaceName: "consumer-web", cover: "/examples/consumer-starter/cover.png", prompt: makePrompt({ titleZh: "C 端 Web", titleEn: "the Consumer Web starter", workspaceName: "consumer-web", - presetId: "1.fnav.ed", - stackZh: "Next.js + Vercel + dotenv", - stackEn: "Next.js + Vercel + dotenv", + presetId: "1.fna.ed", + stackZh: "Next.js + dotenv", + stackEn: "Next.js + dotenv", }), tags: ["nextjs", "react", "vercel", "consumer"], }, @@ -177,16 +177,16 @@ export const examples: Example[] = [ en: "React + Vite CSR with NestJS API, for B2B admin and internal tooling", }, baseTemplates: ["react-spa", "nestjs-api"], - presetId: "1.bnekh.frsc.ed", + presetId: "1.bne.frs.ed", workspaceName: "admin-dashboard", cover: "/examples/admin-starter/cover.png", prompt: makePrompt({ titleZh: "后台管理", titleEn: "the Admin Dashboard starter", workspaceName: "admin-dashboard", - presetId: "1.bnekh.frsc.ed", - stackZh: "React SPA + Cloudflare + NestJS API + Kustomize + Docker Hub + dotenv", - stackEn: "React SPA + Cloudflare + NestJS API + Kustomize + Docker Hub + dotenv", + presetId: "1.bne.frs.ed", + stackZh: "React SPA + NestJS API + dotenv", + stackEn: "React SPA + NestJS API + dotenv", }), tags: ["react", "vite", "nestjs", "admin"], }, diff --git a/apps/docs/src/data/templates.ts b/apps/docs/src/data/templates.ts index d14f60ff..835888d1 100644 --- a/apps/docs/src/data/templates.ts +++ b/apps/docs/src/data/templates.ts @@ -2,12 +2,6 @@ import type { LocalizedText } from "@/data/examples"; export type TemplateKind = "frontend" | "backend" | "library"; -export type DeployOption = { - code: string; - id: string; - name: LocalizedText; -}; - export type TemplateMeta = { id: string; code: string; @@ -18,46 +12,8 @@ export type TemplateMeta = { toolchain: "node" | "go"; defaultName: string; tags: string[]; - /** Empty array = template forbids a deploy code (e.g. expo / electron / library). */ - deployOptions: DeployOption[]; - /** Code of the default deploy option, or null if the template has no deploy. */ - defaultDeployCode: string | null; -}; - -const DEPLOY_LIBRARY: Record = { - kustomize: { zh: "Kustomize (k8s)", en: "Kustomize (k8s)" }, - vercel: { zh: "Vercel", en: "Vercel" }, - cloudflare: { zh: "Cloudflare Pages", en: "Cloudflare Pages" }, - "aws-s3": { zh: "AWS S3 静态托管", en: "AWS S3 static" }, - "aliyun-oss": { zh: "阿里云 OSS", en: "Aliyun OSS" }, - "tencent-cos": { zh: "腾讯云 COS", en: "Tencent COS" }, - r2: { zh: "Cloudflare R2", en: "Cloudflare R2" }, - minio: { zh: "MinIO", en: "MinIO" }, - rustfs: { zh: "RustFS", en: "RustFS" }, - edgeone: { zh: "腾讯云 EdgeOne", en: "Tencent EdgeOne" }, -}; - -const DEPLOY_CODE: Record = { - kustomize: "k", - vercel: "v", - cloudflare: "c", - "aws-s3": "s", - "aliyun-oss": "a", - "tencent-cos": "t", - r2: "2", - minio: "m", - rustfs: "r", - edgeone: "e", }; -function deploy(ids: string[]): DeployOption[] { - return ids.map((id) => ({ - id, - code: DEPLOY_CODE[id] ?? "", - name: DEPLOY_LIBRARY[id] ?? { zh: id, en: id }, - })); -} - export const templates: TemplateMeta[] = [ { id: "nestjs-api", @@ -72,8 +28,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "api", tags: ["api", "nestjs", "typescript"], - deployOptions: deploy(["kustomize"]), - defaultDeployCode: "k", }, { id: "go-api", @@ -88,8 +42,6 @@ export const templates: TemplateMeta[] = [ toolchain: "go", defaultName: "api", tags: ["api", "go", "kustomize"], - deployOptions: deploy(["kustomize"]), - defaultDeployCode: "k", }, { id: "nextjs-app", @@ -104,8 +56,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "web", tags: ["web", "nextjs", "react"], - deployOptions: deploy(["kustomize", "vercel", "cloudflare"]), - defaultDeployCode: "k", }, { id: "react-spa", @@ -120,18 +70,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "web", tags: ["web", "vite", "react"], - deployOptions: deploy([ - "aws-s3", - "cloudflare", - "vercel", - "r2", - "aliyun-oss", - "tencent-cos", - "minio", - "rustfs", - "edgeone", - ]), - defaultDeployCode: "s", }, { id: "astro-site", @@ -146,18 +84,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "site", tags: ["web", "astro", "content"], - deployOptions: deploy([ - "aws-s3", - "cloudflare", - "vercel", - "r2", - "aliyun-oss", - "tencent-cos", - "minio", - "rustfs", - "edgeone", - ]), - defaultDeployCode: "s", }, { id: "starlight-docs", @@ -172,18 +98,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "docs", tags: ["docs", "starlight", "astro"], - deployOptions: deploy([ - "aws-s3", - "cloudflare", - "vercel", - "r2", - "aliyun-oss", - "tencent-cos", - "minio", - "rustfs", - "edgeone", - ]), - defaultDeployCode: "s", }, { id: "electron-app", @@ -198,8 +112,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "desktop", tags: ["desktop", "electron", "react"], - deployOptions: [], - defaultDeployCode: null, }, { id: "expo-mobile", @@ -214,8 +126,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "mobile", tags: ["mobile", "expo", "native"], - deployOptions: [], - defaultDeployCode: null, }, { id: "ts-library", @@ -230,8 +140,6 @@ export const templates: TemplateMeta[] = [ toolchain: "node", defaultName: "shared", tags: ["library", "typescript", "package"], - deployOptions: [], - defaultDeployCode: null, }, { id: "go-lib", @@ -246,8 +154,6 @@ export const templates: TemplateMeta[] = [ toolchain: "go", defaultName: "lib", tags: ["library", "go", "golang", "module"], - deployOptions: [], - defaultDeployCode: null, }, ]; diff --git a/apps/docs/src/lib/create-command.ts b/apps/docs/src/lib/create-command.ts index b9e94017..5db34340 100644 --- a/apps/docs/src/lib/create-command.ts +++ b/apps/docs/src/lib/create-command.ts @@ -35,8 +35,7 @@ export function projectsFromPresetId(presetId: string): CommandProject[] { const kind = segment[0] as PresetKind | "e"; if (kind !== "b" && kind !== "f" && kind !== "l") continue; const templateCode = segment.slice(1, 3); - const deployCode = kind === "l" ? undefined : segment[3]; - const containerCode = kind === "l" ? undefined : segment[4]; + if (segment.length !== 3) return []; const template = templates.find( (t) => t.presetKind === kind && t.code === templateCode, ); @@ -53,8 +52,6 @@ export function projectsFromPresetId(presetId: string): CommandProject[] { projects.push({ kind, tcode: template.code, - dcode: deployCode, - ccode: containerCode, templateId: template.id, title: template.title, defaultName, @@ -82,8 +79,6 @@ export function buildCustomPresetCommand(input: { sorted.map((project) => ({ kind: project.kind, tcode: project.tcode, - dcode: project.dcode, - ccode: project.ccode, })), envCode, ); @@ -99,10 +94,7 @@ export function sortCommandProjects(projects: T[]): T[] { const kindDelta = KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind); if (kindDelta !== 0) return kindDelta; if (a.tcode !== b.tcode) return compareAscii(a.tcode, b.tcode); - if ((a.dcode ?? "") !== (b.dcode ?? "")) { - return compareAscii(a.dcode ?? "", b.dcode ?? ""); - } - return compareAscii(a.ccode ?? "", b.ccode ?? ""); + return 0; }); } diff --git a/apps/docs/src/lib/preset.ts b/apps/docs/src/lib/preset.ts index 50ef54e8..75dbc470 100644 --- a/apps/docs/src/lib/preset.ts +++ b/apps/docs/src/lib/preset.ts @@ -1,12 +1,10 @@ // Client-side One CLI preset id encoder. // Mirrors packages/cli/internal/modules/preset/spec.go + codes.go (v1). -// Grammar: 1[.[[]]]+[.e] +// Grammar: 1[.]+[.e] // kind: 'f' (frontend) | 'b' (backend) | 'l' (library) // tcode: 2-char [a-z0-9] template code -// dcode: 1-char deploy code (optional; empty = template default) -// ccode: 1-char container code (optional; only meaningful with kustomize) // envCode: 1-char env code (optional; empty = workspace default dotenv) -// Canonical ordering: items sorted by kind (b < f < l). +// Canonical ordering: items sorted by kind (b < f < l), then template code. export type PresetKind = "f" | "b" | "l"; export type PresetEnv = "d" | "i"; @@ -14,8 +12,6 @@ export type PresetEnv = "d" | "i"; export type PresetItem = { kind: PresetKind; tcode: string; - dcode?: string; - ccode?: string; }; const KIND_ORDER: PresetKind[] = ["b", "f", "l"]; @@ -26,10 +22,10 @@ export function encodePreset( ): string { if (items.length === 0) return ""; const sorted = [...items].sort( - (a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind), + (a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.tcode.localeCompare(b.tcode), ); const segs = sorted.map( - (it) => `${it.kind}${it.tcode}${it.dcode ?? ""}${it.ccode ?? ""}`, + (it) => `${it.kind}${it.tcode}`, ); const out = ["1", ...segs]; if (envCode) out.push(`e${envCode}`); diff --git a/apps/docs/src/lib/source.ts b/apps/docs/src/lib/source.ts index de325432..5316d05d 100644 --- a/apps/docs/src/lib/source.ts +++ b/apps/docs/src/lib/source.ts @@ -1,24 +1,9 @@ -import { docs, tutorials } from "../../.source"; +import { docs, tutorials } from "../../.source/server"; import { loader } from "fumadocs-core/source"; import { icons } from "lucide-react"; import { createElement } from "react"; import { i18n } from "@/i18n"; -// fumadocs-mdx@11.10.1 .toFumadocsSource() 返回的是 `{ files: () => [...] }` -// (function),但 fumadocs-core@15.8.5 的 loader 把 `source.files` 当数组用。 -// TypeScript 用 fumadocs-core 的类型推断 (files: VirtualFile[]),所以这里强转 -// 一下 unknown 再判断 runtime 形状。 -function resolveFiles(input: { - toFumadocsSource: () => unknown; -}): ReadonlyArray { - const fumadocsSource = input.toFumadocsSource() as unknown as { - files: ReadonlyArray | (() => ReadonlyArray); - }; - return typeof fumadocsSource.files === "function" - ? fumadocsSource.files() - : fumadocsSource.files; -} - function makeIconResolver() { return (icon?: string) => { if (!icon) return; @@ -31,8 +16,7 @@ function makeIconResolver() { export const source = loader({ baseUrl: "/docs", i18n, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - source: { files: resolveFiles(docs) as any }, + source: docs.toFumadocsSource(), icon: makeIconResolver(), }); @@ -41,7 +25,6 @@ export const source = loader({ export const tutorialsSource = loader({ baseUrl: "/tutorials", i18n, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - source: { files: resolveFiles(tutorials) as any }, + source: tutorials.toFumadocsSource(), icon: makeIconResolver(), }); diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 4533810d..3b98f540 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "paths": { "@/*": [ @@ -34,7 +34,8 @@ ".next/types/**/*.ts", ".source", "next-env.d.ts", - "dist/types/**/*.ts" + "dist/types/**/*.ts", + "dist/dev/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/docs/plans/2026-09-28-dev-build-terminal-ui.md b/docs/plans/2026-09-28-dev-build-terminal-ui.md new file mode 100644 index 00000000..26a47306 --- /dev/null +++ b/docs/plans/2026-09-28-dev-build-terminal-ui.md @@ -0,0 +1,232 @@ +# one dev / one build 终端界面与多项目执行规划 + +状态:已实施前三阶段的 Unix 版本;Windows ConPTY TUI 留待第四阶段。下文保留设计时的现状调查,实际行为以文末实施记录为准。 + +## 1. 目标与默认体验 + +- 一个实际执行任务:直接连接原生终端,保留命令自身的颜色、排版、进度刷新和输入交互。 +- 多个实际执行任务:在交互式终端默认进入 TUI,按项目查看独立输出。 +- dev 和 build 共用终端与进程会话能力,各自保留常驻服务、有限构建任务的执行规则。 +- CI、管道、重定向和 JSON/YAML 输出使用适合自动化的输出方式。 +- 同一 Workspace 内可以指定多个项目,也可以继续一次启动全部项目。 + +用户提到的参考界面应是 Turborepo 的任务 TUI。Turbopack 是相关生态中的打包器;本方案参考 Turborepo 的项目列表、任务状态、独立日志和输入模式。 + +## 2. 当前实现调查 + +已核对本机 `one --help`、`one dev --help`、`one build --help` 和仓库源码。 + +| 功能 | 当前行为 | +| --- | --- | +| `one dev` | 并行启动所有声明了 `domains.dev.command` 的项目 | +| `one dev web` | 启动一个项目;支持项目名或相对路径 | +| `one dev web api` | 不支持,Cobra 目前限制最多一个位置参数 | +| dev 输出 | stdout/stderr 按行加项目前缀;没有连接子进程 stdin | +| dev 生命周期 | 任一进程退出后停止全部进程;Unix 使用进程组,Windows 使用 Job Object | +| `one build` | 按本地 Node 依赖排序后逐个执行;首个失败后停止后续任务 | +| `one build web` | 只构建该项目,不自动加入它的本地依赖 | +| build 输出 | 接受 stdin,但 stdout/stderr 经过同一个按行前缀 writer | +| `one run` | 负责 runtime、PATH、环境变量注入,项目命令已经继承标准输入输出 | +| 现有依赖 | 已有 Lip Gloss、间接依赖 Bubble Tea v2,以及用于终端测试的 creack/pty | + +关键入口: + +- `packages/cli/internal/transport/cobra/dev/cmd.go` +- `packages/cli/internal/modules/development/process/ops.go` +- `packages/cli/internal/modules/development/process/supervisor*.go` +- `packages/cli/internal/transport/cobra/build/cmd.go` +- `packages/cli/internal/modules/build/plan.go` +- `packages/cli/internal/modules/build/service.go` +- `packages/cli/internal/transport/cobra/run/cmd.go` +- `packages/cli/internal/platform/process/` +- `packages/cli/internal/platform/output/mode.go` + +当前颜色与动态输出失真的原因:外层 writer 让子进程看到的是管道,并按换行缓冲、添加前缀。仅删除前缀或设置 FORCE_COLOR,不能恢复终端检测、回车刷新和输入交互。 + +## 3. 命令设计 + +以下为拟新增的用法,当前版本尚未实现: + +```sh +one dev web api # 并行启动指定项目 +one dev apps/web services/api # 同样支持相对路径 +one dev --select # 搜索并多选项目,再启动 +one dev web --ui=tui # 单项目也可以主动使用 TUI +one dev web api --ui=stream # 带项目标识的连续日志 +one build web api # 构建指定项目 +one build --concurrency=4 # 按依赖关系同时构建最多四个项目 +``` + +保留已有 `one dev` / `one build` 不带参数的全部项目行为;不额外加入必经选择界面。单个 `-p/--project` 保持兼容,第一版用多个位置参数表达多选,避免同时扩展两套多选语法。`-p` 与多个位置参数混用应明确报错。 + +选择器先解析项目名和路径、去重、验证项目操作,再开始准备依赖。显式选中了没有对应命令的项目时直接报错;无参数的全部项目模式继续跳过没有对应操作的项目。名称与路径别名应复用现有解析逻辑。 + +界面参数:`--ui=auto|raw|tui|stream`,默认 `auto`。 + +| 场景 | auto 的行为 | +| --- | --- | +| 真实终端,只有一个执行任务 | raw:直接连接终端,无项目日志前缀 | +| 真实终端,有多个执行任务 | tui:列表 + 当前任务终端 | +| 管道、CI、TERM=dumb、终端能力不足 | stream / 结构化结果,不进入全屏界面 | +| `-o json` / `-o yaml` | stdout 保留结果协议,子进程日志写 stderr | +| `--dry-run` | 返回执行计划,不启动 TUI、不启动进程、不安装依赖 | + +任务数量以执行计划中实际要运行的任务为准,不计跳过的项目。若将来支持自动补齐构建依赖,也按展开后的任务数判断。 + +显式 `--ui=tui` 遇到非交互终端或结构化输出应给出清晰错误;`auto` 才做自动降级。多任务不能使用 raw,否则多个应用会争用同一个终端。`--ui=raw` 与结构化输出冲突时明确报错。 + +## 4. TUI 布局与交互 + +```text +One dev · workspace 2 running · 1 failed +┌───────────────────────┬───────────────────────────────────────────┐ +│ Projects │ web · running · 00:24 │ +│ > ● web running │ │ +│ ● api running │ VITE │ +│ × worker failed │ Local: http://localhost:5173/ │ +│ │ │ +│ │ 当前项目的终端输出 │ +└───────────────────────┴───────────────────────────────────────────┘ +↑↓ select · Enter interact · r restart · / search · ? help · Ctrl+C stop +``` + +- 左侧:项目名、运行状态、退出码或耗时。区分 starting、running、stopping、stopped、failed;build 另有 pending、succeeded、blocked、skipped。 +- 右侧:只展示选中项目,不加逐行项目前缀。颜色、换行、回车覆盖、清屏与进度刷新由该项目独立的终端状态管理。 +- 底部:显示当前可用快捷键、是否正在跟随最新输出、是否进入输入模式。 +- 日志可滚动;查看历史时不被新日志强制拉回底部;提供恢复跟随按钮或快捷键。 +- 窄终端可隐藏项目列表;极小尺寸显示简化界面,仍保留退出能力。 +- 运行状态只说明进程存活,不在没有健康检测依据时标记 ready。 +- One 的依赖安装、runtime 准备与需要用户回答的提示在进入 TUI 前完成,避免多个准备进程抢占 stdin。 + +输入模式必须与导航模式明确区分: + +- 导航模式:方向键切换项目,`r` 重启当前 dev 项目,`s` 停止当前 dev 项目;Ctrl+C 停止会话内全部进程。 +- Enter:把键盘输入交给当前项目;普通快捷键随之交给子进程,例如 Vite 的 h/r。 +- Ctrl+]:退出输入模式,回到项目导航;底部持续显示提示。 +- 输入模式里的 Ctrl+C 交给当前项目;全局停止需先退出输入模式。 +- 搜索模式单独处理 Esc,不将搜索文字转发给子进程。 + +## 5. 保留原始输出的实现 + +### 单任务 raw + +将子进程的 stdin、stdout、stderr 连接至真实终端,继续通过 `one run` 执行项目命令,复用环境变量和 runtime 逻辑。原生输出的保真度最高;One 不给业务日志添加前缀,也不按行重写日志。 + +还要正确处理前台进程组、信号、退出码和终端状态恢复。不能只在现有 Setpgid 逻辑中加入 stdin,否则后台进程组读取控制终端可能被暂停。复核整个 `one → one run → mise → __exec → 应用` 链路,避免重复发送中断信号。 + +### 多任务 TUI + +每个任务拥有一个独立伪终端(PTY),应用据此判断自己运行在终端中。输出按字节增量流入终端模拟器,由模拟器维护屏幕和滚动历史,再由 TUI 绘制当前任务。 + +- Unix:评估复用已有 creack/pty。 +- TUI 外壳:复用 Bubble Tea v2 和 Lip Gloss。 +- ANSI/VT 终端模拟器:先验证现有 Go 实现对需要的控制序列的兼容性,再确定依赖;不把普通文本 viewport 当作终端模拟器。 +- resize:向所有任务同步当前终端窗格大小,包含暂时没有选中的任务。 +- 日志高流量时限制渲染频率,保持读取持续进行;每个任务的滚动历史设上限并提示截断,避免长时间开发无限占内存。 +- PTY 输出通常将 stdout/stderr 合并,这是终端模式的明确边界;stream 模式继续区分来源。 +- ANSI 解析不等同于完整终端兼容。验收覆盖目标工具的颜色、回车、清屏、Unicode 和输入;不承诺任意嵌套全屏程序都完全一致。 +- `NO_COLOR` 等用户设置继续有效,不能无条件覆盖为 FORCE_COLOR。 + +Windows 需要 ConPTY 适配,不能直接使用仅支持 Unix 的 creack/pty。第一阶段 raw 和 stream 在 Windows 保持可用;TUI 若暂不支持,则 auto 明确降级,正式宣称跨平台 TUI 前需完成 Windows 验收。 + +## 6. 进程规则与构建调度 + +### dev + +首版保留当前“任一进程退出则停止整组”的默认策略,UI 展示各项目最后状态和失败输出,行为不因使用 TUI 或 stream 而改变。 + +第二阶段增加 `--keep-going`:一个项目退出后,其他项目继续运行;TUI 保留失败项,支持单独重启。这个模式更适合长期联调,后续是否成为默认值应作为独立行为变更记录。所有项目结束时退出;用户退出后不留下后台常驻服务。 + +单独停止/重启是用户操作,不应触发“意外退出停止整组”。每次重启前必须确认旧进程树已退出,避免端口被旧进程占用。重启输出应标注运行批次。 + +### build + +先复用 TUI 和 raw 输出,保留串行执行、首个失败后不再启动后续任务。 + +之后加入 `--concurrency=N`,初始默认值保持 1: + +- 先根据本地 Node 依赖构建有向无环图;只有前置构建成功才允许启动依赖它的项目。 +- 多项目显式选择同样需要处理所选项目之间的依赖,目前此逻辑只在完整工作区构建时启用。 +- 继续保持显式项目选择的范围;不悄悄将单项目 build 改成包含所有依赖的构建。将来可另加 `--with-deps` 显式展开依赖闭包。 +- 未选中的依赖假定已准备好,并在执行计划中说明;有依赖但缺少 build 操作的源码包不能被视为构建失败。 +- Node 以外的跨项目构建依赖不能凭项目排列猜测;首次沿用现有支持范围,额外依赖关系单独设计。 +- 出现失败后停止启动新任务,允许已经运行的无关任务结束;依赖失败项的任务标记 blocked,其余未启动任务标记 not_run。 +- 用户 Ctrl+C 立即进入整组取消与进程树清理。 +- 全部完成后自动退出 TUI,在普通终端保留结果摘要与失败任务日志尾部;构建成功和失败都不能等待用户按键才返回 shell。 +- 单任务返回原退出码;多任务采用稳定的任务顺序汇总首个实际失败码;用户中断保持 130/143。 + +## 7. 代码组织 + +建议按现有分层实现以下能力,具体包名在实施时依据架构测试确定: + +1. 项目选择与计划:复用 application/execution 的名称、路径解析;dev/build 分别形成任务计划。 +2. 共享进程会话:提供 Start、WriteInput、Resize、Stop、Wait 与状态/输出事件,不依赖 TUI。 +3. 平台适配:Unix 进程组 + PTY;Windows Job Object + ConPTY。每个项目可独立清理整个进程树。 +4. 输出适配:raw、stream、tui 共享执行结果与错误码,避免复制环境注入逻辑。 +5. Cobra 层:解析多项目、ui、select、concurrency、keep-going,并维持帮助、国际化和 dry-run 协议。 + +`one run` 保持项目命令的执行边界;本轮不在 dev/build 内复制密钥加载或 mise 启动逻辑。结构化结果继续通过现有 output 包发出,新字段和状态需同步协议快照。 + +## 8. 实施顺序 + +### 第一阶段:单项目原生终端与多项目选择 + +- 为 dev/build 增加多个位置参数,复用项目集合验证。 +- 单任务默认 raw,多任务先沿用 stream。 +- 完成真实 TTY 输入、颜色、信号、进程树退出、JSON/YAML 分流和 dry-run 回归。 +- 这一阶段即可交付“一个项目保留原输出”和“指定多个项目一起启动”。 + +### 第二阶段:多项目 dev TUI + +- 先用实际 Vite、Next、Go 服务和模拟动态终端输出验证 PTY + 终端模拟器。 +- 实现项目列表、独立终端、输入模式、滚动、resize、停止与重启。 +- 增加 --select、--keep-going;Unix 完整交付,Windows 明确能力边界。 + +### 第三阶段:build TUI 与依赖并行 + +- 接入 build 的任务状态、耗时、完成摘要和失败输出。 +- 保持 concurrency=1 默认,开放按依赖图的有限并行。 +- 验证失败依赖不会继续构建、被取消任务不会留下进程。 + +### 第四阶段:Windows TUI 与体验补齐 + +- 完成 ConPTY、输入、resize、Job Object 清理和 Windows Terminal 验收。 +- 再考虑保存常用项目组合;跨 Workspace 编排另行规划。 + +## 9. 验收标准 + +- 单项目 dev/build 在伪终端测试中能检测到真实 TTY,保留 ANSI、回车覆盖和无需换行的输出,键盘能到达应用。 +- 多项目选择支持名称、路径、去重和缺少命令报错;准备共享依赖不重复执行。 +- TUI 切换项目时屏幕互不污染,重绘与窗口变化不会丢失退出状态;中文、emoji、长行与高频输出可用。 +- 开启输入模式后应用快捷键有效;退出输入模式和停止全部会话可预测。 +- 停止或重启 Node/pnpm/mise 包装的进程后没有遗留子进程,端口被释放;正常退出、失败、启动失败和强制取消都恢复终端。 +- CI/管道不出现全屏控制序列;JSON/YAML stdout 可解析;--dry-run 不安装、不加载密钥、不启动进程。 +- 构建并发不超过限制,依赖顺序正确,循环依赖在执行前被拒绝,失败和中断退出码稳定。 +- 同步中英文帮助、文档、参考快照;通过相关单元测试、PTY E2E 和仓库 task check。 + +## 10. 参考 + +- [Turborepo 开发任务与终端 UI](https://turborepo.dev/docs/crafting-your-repository/developing-applications) +- [Turborepo 配置:ui、persistent、interactive](https://github.com/vercel/turborepo/blob/main/apps/docs/content/docs/reference/configuration.mdx) +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) +- [creack/pty](https://github.com/creack/pty) +- [待评估的 Go VT 实现](https://github.com/charmbracelet/x/tree/main/vt) + + +## 实施记录 + +- 已实现多项目名称/路径选择与去重、--select、--ui、--keep-going 和 build --concurrency。 +- dev/build 共用 platform/taskrun 的调度、状态和进程控制;旧执行器的进程树回归测试已迁移。 +- 单任务原始输出在 Unix 通过透明 PTY 转发实现,而非简单继承文件描述符。这使原始输入/输出与进程组清理同时成立,并保留程序的 TTY 检测、ANSI、回车刷新和交互。 +- 多任务通过 Bubble Tea、Lip Gloss、charmbracelet/x/vt 和独立 PTY 实现项目切换、输入、窗口尺寸同步、滚动、搜索、停止和重启;历史上限为每任务 3000 行。 +- build 按所选项目本地依赖调度,默认并发 1,失败依赖标记 blocked;构建结束自动退出并保留失败输出。 +- 管道/JSON/YAML 的 stdout 保持可解析,dev 日志改到 stderr;这修复了原有 dev JSON 混入日志的问题。 +- Windows 保留原生与 stream 输出,每个任务使用独立 Job Object 清理;未实现 ConPTY,不宣称 Windows TUI 已可用。 + +### 本轮验证 + +- `task check` 通过:文档/帮助/架构约束、Go vet/gofmt、全量 Go 与端到端测试、Dashboard 检查和 60 项前端测试。 +- taskrun、build、execution 和 development/process 的 race 检测通过。 +- 真实 PTY 端到端验证:单任务 dev/build 的 TTY 检测、ANSI 原样输出与输入;多项目 dev 的输入、尺寸变化、单独重启和 Ctrl+C 清理;build TUI 失败摘要与自动退出。 +- Windows amd64 与 macOS arm64 交叉编译通过;这不代表 Windows/macOS 真机交互已验证。 +- 新增 VT 依赖已锁定版本;单项目 raw 和多项目 TUI 在当前 Linux 环境验证。 diff --git a/docs/reviews/dashboard-universe-design.md b/docs/reviews/dashboard-universe-design.md new file mode 100644 index 00000000..d415bca7 --- /dev/null +++ b/docs/reviews/dashboard-universe-design.md @@ -0,0 +1,58 @@ +# Dashboard 逐页设计复核 + +日期:2026-09-28。范围:`apps/dashboard` 的全部路由、核心弹窗及异常状态。保留当前橙色主题和现有 API。 + +## 业务与提交边界 + +- 首页管理本机登记的工作区;移除只删除登记记录。 +- 工作区按项目和环境浏览。项目基础配置、环境配置以及工作区后端设置共同产生 Manifest 草稿,经差异预览后统一保存。 +- 共享凭据管理独立的 Infisical 存放项目、环境、目录与变量。切换浏览环境不改变默认位置。 +- 项目及工作区密钥直接提交到 Infisical。账号设置管理与 CLI 共用的会话。 + +## 页面清单、证据和改动 + +走查先在浏览器逐页操作并截图,再对照组件源码、业务接口和 Universe Design 规范。截图已在本任务的工具输出中显示;本文件不包含导出的图片附件。 + +| 页面 / 状态 | 走查发现 | 本次实现 | +| --- | --- | --- | +| 首页 `/` | 注册说明占据首屏,标题尺度与其他页面不一致 | 注册帮助可展开;统一页面标题和内容宽度;保留搜索、状态筛选和刷新失败恢复 | +| 项目概览 `/workspace/:entryId` | 空草稿也显示草稿标识;运行字段列布局留空;密钥混在概览 | 仅存在修改时显示草稿;基本信息与开发构建明确分区;依可用内容宽度分栏;密钥移到环境页 | +| 项目环境页 | 开关嵌套边框过多;变量声明和远程密钥关系不清 | 采用标签在左、开关在右的设置行;展示声明名称;独立展示直接提交的远程密钥 | +| 项目导航 | 项目种类图标不够明确,工作区设置只有齿轮 | 应用、服务、包使用 Code / Server / Library 图标;工作区设置显示文字;移动端使用项目选择器 | +| 工作区设置弹窗 | 固定大尺寸且大片空白;修改后保存按钮被弹窗遮挡 | 自适应高度、最大 840px 宽;线条式标签页;内部滚动;底部关闭和草稿保存入口 | +| 工作区后端配置 | 加载、未登录、无项目和失败缺少下一步 | 增加加载占位、登录与无项目提示、可重试错误;只读禁止变更 | +| Manifest 预览 | 全量差异中难以找到改动;预览失败只能关闭重开 | 保留完整差异,增加修改字段摘要和定位首处修改;失败可原地重试;保留失败草稿 | +| 共享凭据 `/global` | 标题小;目录、工具栏、表格缺乏层级;无结果时空白 | 页面标题、存储上下文、目录导航、位置栏、搜索、计数与表格分区;区分加载、错误、初始空数据与搜索无匹配 | +| 凭据表格 | 每行四个文字操作拥挤,复制无反馈 | 查看与复制使用带提示的图标按钮,编辑/删除收进菜单;复制成功反馈;菜单编辑/删除关闭后焦点归还原行按钮 | +| 新建 / 编辑变量 | 缺少表单 Enter 提交;取消和关闭行为不同;有值时 X 静默无效 | 有标签的表单、显隐切换、明确页脚;防重复提交;失败保留输入;取消、Esc、外点、X 统一草稿确认 | +| 新建目录 | 无可见字段标签、无取消按钮,空格也可提交 | 标签、提交与取消齐全,拒绝空白名称,草稿关闭确认 | +| 删除变量 | 普通 Dialog 默认焦点可能落在删除按钮 | 使用 AlertDialog,优先取消,明确对象、环境与目录 | +| 默认存放位置 | 设置内容和页面操作重复,表单挤在一行 | 独立配置分区,响应式分栏,底部取消/保存;错误可重试;创建项目后仍需明确保存位置 | +| 新建存放项目 | 编辑关闭缺少草稿保护 | 保留表单与失败恢复,补齐关闭确认和统一弹窗尺寸 | +| 项目 / 工作区密钥 | 报错直接展示 CLI 内部指导;表格无搜索;保存错误只有 Toast | 根据未绑定状态提供可理解的连接动作;只读禁止连接写入;搜索、收起次要操作、复制反馈、就地错误、键盘提交和关闭保护 | +| 账号设置 `/settings` | 无页标题;账号属性是散落段落;加载时短暂出现未登录操作 | 页标题、连接状态、账号属性分组、共享凭据入口;加载和未登录互斥;失败可重试;自定义实例使用 URL 表单 | +| 语言设置 | 仅图标,无法看出当前值 | 设置页显示当前语言,保留全局紧凑入口 | +| 工作区 ID 冲突 | 只读编辑与一般编辑视觉易混淆 | 明确只读标识,保留可查看字段;不提供保存与绑定操作 | +| 缺失 / 无效 / 未登记工作区 | 无统一内容边距,长路径裁切,部分状态缺少返回操作 | 可滚动状态页、统一间距、长路径换行、返回工作区入口 | +| 404 | 空白区域只显示一句 404,面包屑仍显示首页 | 独立状态图标、说明、返回操作与正确面包屑 | +| 兼容路由 | `/profile`、`/section/*`、`/settings/*` 使用重定向 | 保留重定向与环境查询参数,回归测试覆盖 | + +## 设计依据 + +- Universe Design:表单紧凑项间距 20px、列间距参考 24px;标签与字段成组。 +- 对话框使用 420 / 600 / 840 / 1080px 对应任务复杂度;操作页脚明确;复杂内容滚动。 +- 一级内容切换采用线条式 Tabs;默认按钮 32px,图标按钮 28px。 +- 表格超过三项操作时保留两项,其余收起;单元格横向 12px。 +- 初始空数据、搜索无结果、加载和错误分开;提供创建、清除搜索、重试等实际动作。 +- 使用 Lucide 的统一语义图标及 16 / 20 / 24px 尺度;Lucide 是本项目的工程选择。 +- 颜色沿用橙色 Token,界面表面与选中态从主题派生;错误和成功保留语义色。 + +依据文件:`universe-design/references/interaction.md`、`pages/dialog.md`、`form.md`、`icon.md`、`tabs.md`、`table.md`、`empty.md`、`notice.md`。 + +## 验证与边界 + +- 浏览器走查:主页面、存放位置、新建项目、变量/目录弹窗、项目配置、工作区设置两页、草稿预览、移除确认、ID 冲突、未知工作区、404。 +- 布局复核包含桌面 1200px、移动端 390px、中文及深色模式;检查页面横向溢出、焦点、弹窗操作可达性。 +- 本地草稿修改仅用于预览和放弃,检查后已清理。 +- 远程列表当前为空,已有数据的查看/编辑/删除与失败恢复通过隔离的模拟 API 测试验证;未在真实 Infisical 中创建、修改或删除凭据,也未退出真实账号。 +- 验证通过:10 个测试文件、60 项行为测试;`pnpm --filter one-serve-web build`;`pnpm --filter one-serve-web check`;`git diff --check HEAD`。 diff --git a/go.work b/go.work index 3b50c2a0..474c4706 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.25.0 +go 1.26.0 use ( ./packages/cli diff --git a/go.work.sum b/go.work.sum index 12ccc922..c2ac4c19 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,24 +1,48 @@ cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/longrunning v0.5.9/go.mod h1:HD+0l9/OOW0za6UWdKJtXoFAX/BGg/3Wj8p10NeWF7c= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/ThalesGroup/crypto11 v1.5.0/go.mod h1:sHbXFYNbNLe231R/gmWlE4MXh8dn8n0EqfD+harPBLA= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= +github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto/googleapis/bytestream v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tej9lWiwVvQJP+b43pjJIsr/3mZycXWCIyoiXmbFf40= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260921155816-b14227669459/go.mod h1:RoCpRfcA27uTJ0TZb3Vyad8eLVakUKdLdV2yMdlm2+w= diff --git a/packages/cli/go.mod b/packages/cli/go.mod index f433d9a1..e964a84b 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -1,102 +1,104 @@ module github.com/torchstellar-team/one-cli/packages/cli -go 1.25.0 +go 1.26.0 require ( - github.com/aws/aws-sdk-go-v2 v1.41.7 - github.com/aws/aws-sdk-go-v2/config v1.32.17 - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 - github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 - github.com/aws/smithy-go v1.25.1 + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.10 + 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/huh v1.0.0 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d github.com/creack/pty v1.1.24 - github.com/gofrs/flock v0.8.1 - github.com/infisical/go-sdk v0.7.1 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/gofrs/flock v0.13.1 + github.com/infisical/go-sdk v0.8.0 + github.com/muesli/cancelreader v0.2.2 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.9 + github.com/spf13/pflag v1.0.10 github.com/torchstellar-team/one-cli/packages/kernel v0.0.0 - golang.org/x/mod v0.31.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/mod v0.41.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 gopkg.in/yaml.v3 v3.0.1 ) replace github.com/torchstellar-team/one-cli/packages/kernel => ../kernel require ( - cloud.google.com/go/auth v0.18.1 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.1.11 // indirect + cloud.google.com/go/auth v0.24.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + cloud.google.com/go/iam v1.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.47.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect + github.com/aws/smithy-go v1.28.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.1.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-resty/resty/v2 v2.13.1 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect - github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/s2a-go v0.1.10 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect + github.com/googleapis/gax-go/v2 v2.26.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect + github.com/oracle/oci-go-sdk/v65 v65.126.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.26.1 // indirect - github.com/sony/gobreaker v0.5.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/sony/gobreaker/v2 v2.4.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/xo/terminfo v1.2.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/time v0.14.0 // indirect - google.golang.org/api v0.267.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.11 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/api v0.299.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/packages/cli/go.sum b/packages/cli/go.sum index eacec133..bcf72416 100644 --- a/packages/cli/go.sum +++ b/packages/cli/go.sum @@ -1,316 +1,238 @@ -cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= -cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +cloud.google.com/go/auth v0.24.0 h1:UYMbF8otPZnLAkNJ5/LYQYOq0ARcJS1P4JqTeMKbCYU= +cloud.google.com/go/auth v0.24.0/go.mod h1:IFG/AMA1VWfuTrdbieEsB2GcpJyJV/phGAvogkOoPR4= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/iam v1.14.0 h1:YRIB1/4xxwJ+NfI6RQr1WaOoHHeV0MizJEj940nBXJA= +cloud.google.com/go/iam v1.14.0/go.mod h1:JTPZfbHdGXJrOYREtkRoFzCDGaBdbA7ILLKO/DDuav0= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= +github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug= +github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= +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= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= -github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4= +github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d h1:4JMIalS3HI866QnQkTaF6cRM6e4CV0J7etYLxBaJqnc= +github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d/go.mod h1:u1LOIABor9JqY54oZdktK3TCRrgzP6tzHrDYx1nd3wY= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -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/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= -github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/s2a-go v0.1.10 h1:EMp+aOuXN6l8cE/gjF5Bt+vyZxsUuyCWe9chDWR/+uU= +github.com/google/s2a-go v0.1.10/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= 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/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= +github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8= +github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.7.1 h1:26upmNiIuXJgZEQdH8ThLZ18EIGdg9ifMm+fBGXSmP0= -github.com/infisical/go-sdk v0.7.1/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= +github.com/infisical/go-sdk v0.8.0 h1:Ipw62V1ptg77OY/5DYH/39D2Ej61Hnrbgn05PCZd32c= +github.com/infisical/go-sdk v0.8.0/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94= -github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/oracle/oci-go-sdk/v65 v65.126.0 h1:RuV0MEcLOOgNOBadYbbkUQriCK4Gm5348F/GdWvYPcI= +github.com/oracle/oci-go-sdk/v65 v65.126.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= +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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= -github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= -github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= +github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -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-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210809222454-d867a43fc93e/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= +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/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.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +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/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -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.6/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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -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.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= -google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +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/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.299.0 h1:b3K+ydSMd0kh6TQI6bJyApRQfqQX2MfSOaVkpM59mJw= +google.golang.org/api v0.299.0/go.mod h1:zlR3GVA8b2R5nv5Ij9UWe37StVB3cxDD7DBFi4ZFsHw= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 h1:GS9OIt/j7c8bvBjYNgnKQysVfmV7e4jM0H8ZK95G4t8= +google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459/go.mod h1:PX5/4vemwVoXtwEcRDWwcR1/r0qrosfx3qoVADMwnVE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/cli/internal/adapters/container/docker/build.go b/packages/cli/internal/adapters/container/docker/build.go deleted file mode 100644 index 23267c2e..00000000 --- a/packages/cli/internal/adapters/container/docker/build.go +++ /dev/null @@ -1,104 +0,0 @@ -package docker - -// build.go: `one container build` execution. Iterates the manifest's -// projects (filtered to those with a Dockerfile), composes the image -// tag via imageTagFor, runs `docker login` lazily when the registry -// has credentials, then shells out to `docker build` via process. -// DryRun=true returns the would-be argv without exec'ing. - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" -) - -// Build invokes `docker build` for one or all projects with a -// Dockerfile. DryRun=true returns the would-be argv without exec'ing. -// -// When in.Registry is set with credentials, runs `docker login` once -// before the first build. The login mutates ~/.docker/config.json — -// that's the user's intent when they ran `one configure add container/`. -func Build(ctx context.Context, in container.BuildInput) (*container.BuildResult, error) { - _ = ctx - m, err := workspace.ReadManifest(in.ProjectRoot) - if err != nil { - return nil, err - } - targets := targetNameSet(in.TargetNames) - res := &container.BuildResult{Schema: SchemaBuild} - matched := false - loggedIn := false - for _, s := range m.Projects { - if len(targets) > 0 { - if _, ok := targets[s.Name]; !ok { - continue - } - } - if in.Project != "" && s.Name != in.Project { - continue - } - matched = true - dockerfile := filepath.Join(in.ProjectRoot, filepath.FromSlash(s.RelativeDir), "Dockerfile") - if _, err := os.Stat(dockerfile); err != nil { - if in.Project != "" { - return nil, cliErrors.New(cliErrors.RUN_DOTENV_MISSING, - fmt.Sprintf("项目 %s 没有 Dockerfile(路径 %s)。先重新 'one add' 该项目让容器配置重建。", s.Name, dockerfile)) - } - continue - } - defaultTag := strings.TrimSpace(in.Tag) - if defaultTag == "" { - var err error - defaultTag, err = defaultImageVersion(in.ProjectRoot, filepath.Join(in.ProjectRoot, filepath.FromSlash(s.RelativeDir)), s.Name) - if err != nil { - return nil, err - } - } - buildProject := s - if c := containerOverride(s); c != nil { - override := *c - override.Image = "" - domains := *s.Domains - domains.Container = &override - buildProject.Domains = &domains - } - imageTag := imageTagFor(buildProject, in.Registry, defaultTag) - argv := []string{"docker", "build"} - if platform := strings.TrimSpace(in.Platform); platform != "" { - argv = append(argv, "--platform", platform) - } - argv = append(argv, "-t", imageTag, filepath.Join(in.ProjectRoot, filepath.FromSlash(s.RelativeDir))) - entry := container.BuildEntry{ - Project: s.Name, - Image: imageTag, - Argv: argv, - DryRun: in.DryRun, - } - if in.DryRun { - res.Built = append(res.Built, entry) - continue - } - if !loggedIn && in.Registry.HasCredentials() { - if err := dockerLogin(in.Registry); err != nil { - return nil, err - } - loggedIn = true - } - if err := process.RunExternal(in.ProjectRoot, argv, "请安装 Docker Desktop / Engine"); err != nil { - return nil, err - } - res.Built = append(res.Built, entry) - } - if !matched && in.Project != "" { - return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, - fmt.Sprintf("没有名为 %s 的项目,或它没有 Dockerfile", in.Project)) - } - return res, nil -} diff --git a/packages/cli/internal/adapters/container/docker/consts.go b/packages/cli/internal/adapters/container/docker/consts.go deleted file mode 100644 index 12364456..00000000 --- a/packages/cli/internal/adapters/container/docker/consts.go +++ /dev/null @@ -1,19 +0,0 @@ -// Package docker is the OCI execution adapter used by the compiled container -// module. It shells out to the `docker` CLI for build / push -// / login; also owns the per-subproject Dockerfile writer (Sync / -// ShouldSync) because Dockerfile authoring is toolchain-driven and -// happens at `one add` time, before any container backend runs. -// Toolchain-specific Dockerfile content lives inside the toolchain -// Adapter (so Rust support adds a Rust adapter, not a Rust plugin). -package docker - -const backendName = "docker" - -// Stable JSON envelope schema strings. JSON consumers + cli snapshot -// tests pin these — DO NOT change without bumping schema versions on -// both the producer and consumer. -const ( - SchemaInfo = "one-cli/container-info/v2" - SchemaBuild = "one-cli/container-build/v2" - SchemaPush = "one-cli/container-push/v1" -) diff --git a/packages/cli/internal/adapters/container/docker/imagetag.go b/packages/cli/internal/adapters/container/docker/imagetag.go deleted file mode 100644 index c3219fba..00000000 --- a/packages/cli/internal/adapters/container/docker/imagetag.go +++ /dev/null @@ -1,73 +0,0 @@ -package docker - -// imagetag.go: image-tag composition rules used by Build / Push + -// `:` <-> `/[/]:` -// fall-backs. Locked by imagetag_test.go: drift here breaks the -// build/push round-trip silently (build succeeds, push fails or -// targets the wrong place). - -import ( - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// imageTagFor derives the image tag for a project. When r is set and -// has a Registry host, prepends `/[/]` so the -// resulting tag is push-ready -// (`//web:`). -// -// Namespace lookup precedence: -// 1. projects[i].container.namespace (per-project manifest field) -// 2. r.Namespace fall-back (tests + future CLI shorthand) -// -// Honors any `projects[].container.image` override; an override -// containing a slash is treated as already-fully-qualified (the user -// is doing their own composition) and the registry prefix is skipped. -func imageTagFor(s workspace.ManifestProject, r *container.Registry, defaultTag string) string { - if defaultTag == "" { - defaultTag = "dev" - } - bare := workspace.ToKebabCase(s.Name) + ":" + defaultTag - override := "" - namespace := "" - if c := containerOverride(s); c != nil { - override = c.Image - namespace = c.Namespace - } - if override != "" { - // Caller-supplied tag already has its colon-tag form (...:dev) — - // keep the v0.5 contract: the override IS the tag, with the - // default version tag suffixed when not already present. - if !strings.Contains(override, ":") { - override += ":" + defaultTag - } - // Registry-qualified override (contains '/') → use as-is. - if strings.Contains(override, "/") { - return override - } - bare = override - } - if r == nil || r.Registry == "" { - return bare - } - if namespace == "" { - namespace = r.Namespace - } - prefix := r.Registry - if namespace != "" { - prefix = prefix + "/" + namespace - } - return prefix + "/" + bare -} - -func imageTagVersion(ref string) string { - ref = strings.TrimSpace(ref) - idx := strings.LastIndex(ref, ":") - slash := strings.LastIndex(ref, "/") - if idx > slash { - return ref[idx+1:] - } - return "" -} diff --git a/packages/cli/internal/adapters/container/docker/imagetag_test.go b/packages/cli/internal/adapters/container/docker/imagetag_test.go deleted file mode 100644 index fabe3999..00000000 --- a/packages/cli/internal/adapters/container/docker/imagetag_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package docker - -// imagetag_test.go locks the tag-composition rules used by Build / -// Push. Three axes interact: registry endpoint (none / host-only / -// host+namespace), per-subproject image override (absent / bare / -// already-namespaced), and the workload name fall-back. Pinning these -// here keeps `one container build` and `one container push` agreeing -// on the same tag for a given input — drift here is silent (the -// build succeeds, the push fails because the tag points at the wrong -// place). HasCredentials behaviour lives next to container.Registry in -// core/container. - -import ( - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestImageTagFor_NoRegistry(t *testing.T) { - s := workspace.ManifestProject{Name: "user-api"} - got := imageTagFor(s, nil, "dev") - if got != "user-api:dev" { - t.Errorf("no registry, no override: got %q want %q", got, "user-api:dev") - } -} - -func TestImageTagFor_RegistryWithoutNamespace(t *testing.T) { - s := workspace.ManifestProject{Name: "user-api"} - r := &container.Registry{Registry: "ghcr.io"} - got := imageTagFor(s, r, "dev") - if got != "ghcr.io/user-api:dev" { - t.Errorf("got %q", got) - } -} - -func TestImageTagFor_RegistryWithNamespace(t *testing.T) { - s := workspace.ManifestProject{Name: "user-api"} - r := &container.Registry{Registry: "registry.example.com", Namespace: "acme-corp"} - got := imageTagFor(s, r, "dev") - want := "registry.example.com/acme-corp/user-api:dev" - if got != want { - t.Errorf("got %q want %q", got, want) - } -} - -// Per-subproject override containing a slash is treated as already- -// fully-qualified — the user is composing the tag themselves, so the -// registry prefix is skipped. -func TestImageTagFor_QualifiedOverride_BypassesRegistry(t *testing.T) { - s := workspace.ManifestProject{ - Name: "user-api", - Domains: &workspace.ProjectDomains{ - Container: &workspace.ProjectContainerOverride{Image: "myorg/user-api:custom"}, - }, - } - r := &container.Registry{Registry: "ghcr.io", Namespace: "ignored"} - got := imageTagFor(s, r, "dev") - if got != "myorg/user-api:custom" { - t.Errorf("qualified override should pass through, got %q", got) - } -} - -// Bare override (no slash, no colon) gets `:dev` appended and respects -// the registry prefix — so `image: api` with a private registry yields -// `//api:dev`. -func TestImageTagFor_BareOverride_RespectsRegistry(t *testing.T) { - s := workspace.ManifestProject{ - Name: "user-api", - Domains: &workspace.ProjectDomains{ - Container: &workspace.ProjectContainerOverride{Image: "api"}, - }, - } - r := &container.Registry{Registry: "registry.example.com", Namespace: "acme-corp"} - got := imageTagFor(s, r, "dev") - want := "registry.example.com/acme-corp/api:dev" - if got != want { - t.Errorf("got %q want %q", got, want) - } -} diff --git a/packages/cli/internal/adapters/container/docker/info.go b/packages/cli/internal/adapters/container/docker/info.go deleted file mode 100644 index ba24180b..00000000 --- a/packages/cli/internal/adapters/container/docker/info.go +++ /dev/null @@ -1,73 +0,0 @@ -package docker - -// info.go: enumerates each project's Dockerfile presence + resolved -// workload name. Pure read-only. Shared `containerOverride` / -// `targetNameSet` helpers live here too because every other verb -// (build / push) consumes them. - -import ( - "os" - "path/filepath" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// Info enumerates each project's Dockerfile presence and resolved -// workload name. Read-only — no side effects. -func Info(in container.InfoInput) (*container.InfoResult, error) { - m, err := workspace.ReadManifest(in.ProjectRoot) - if err != nil { - return nil, err - } - targets := targetNameSet(in.TargetNames) - subs := make([]container.ProjectInfo, 0, len(m.Projects)) - for _, s := range m.Projects { - if len(targets) > 0 { - if _, ok := targets[s.Name]; !ok { - continue - } - } - dockerfile := filepath.Join(in.ProjectRoot, filepath.FromSlash(s.RelativeDir), "Dockerfile") - _, statErr := os.Stat(dockerfile) - info := container.ProjectInfo{ - Name: s.Name, - RelativeDir: s.RelativeDir, - Backend: backendName, - HasArtifact: statErr == nil, - ArtifactPath: dockerfile, - WorkloadName: workspace.ResolveWorkloadName(s.Name, filepath.Join(in.ProjectRoot, filepath.FromSlash(s.RelativeDir))), - } - if c := containerOverride(s); c != nil { - info.ImageOverride = c.Image - } - subs = append(subs, info) - } - return &container.InfoResult{ - Schema: SchemaInfo, - Workspace: in.ProjectRoot, - ContainerBackend: backendName, - Projects: subs, - }, nil -} - -// containerOverride returns the current per-project container override, or -// nil when the project has none. Local helper so call sites read like -// the old `s.Container` access. -func containerOverride(s workspace.ManifestProject) *workspace.ProjectContainerOverride { - if s.Domains == nil { - return nil - } - return s.Domains.Container -} - -func targetNameSet(names []string) map[string]struct{} { - if len(names) == 0 { - return nil - } - out := make(map[string]struct{}, len(names)) - for _, name := range names { - out[name] = struct{}{} - } - return out -} diff --git a/packages/cli/internal/adapters/container/docker/login.go b/packages/cli/internal/adapters/container/docker/login.go deleted file mode 100644 index 5ff98f53..00000000 --- a/packages/cli/internal/adapters/container/docker/login.go +++ /dev/null @@ -1,122 +0,0 @@ -package docker - -// login.go: `docker login`, `docker tag`, `docker image inspect` -// helpers. Login pipes the password via stdin (vs argv) so secrets -// never appear in `ps` output. All three helpers bypass cmdgate -// because they need stdin / stdout wiring that the generic runner -// doesn't expose. - -import ( - "fmt" - "io" - "os" - "os/exec" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// dockerLogin runs `docker login --username --password-stdin ` -// and pipes the password via stdin. Stdin-piping (vs --password=) keeps -// the secret out of the process argv list, where any `ps` would expose -// it. -// -// We bypass process.RunExternal because that wires os.Stdin to the -// child; here we need a controlled stdin reader holding the password. -func dockerLogin(r *container.Registry) error { - if _, err := exec.LookPath("docker"); err != nil { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - "docker 二进制不在 PATH 中;请安装 Docker Desktop / Engine") - } - c := exec.Command("docker", "login", "--username", r.Username, "--password-stdin", r.Registry) - c.Stdin = strings.NewReader(r.Password) - var stdout, stderr strings.Builder - c.Stdout = &stdout - c.Stderr = &stderr - if err := c.Run(); err != nil { - detail := firstNonEmptyLine(stderr.String(), stdout.String()) - msg := fmt.Sprintf("docker login %s failed", r.Registry) - if r.ProfileName != "" { - msg += fmt.Sprintf(" for container profile %q", r.ProfileName) - } - if r.ProfileSource != "" { - msg += fmt.Sprintf(" (source: %s)", r.ProfileSource) - } - if detail != "" { - msg += ": " + detail - } else { - msg += ": " + err.Error() - } - return cliErrors.New(cliErrors.BACKEND_INVOKE_FAILED, msg). - WithContext(map[string]any{ - "registry": r.Registry, - "profile": r.ProfileName, - "profile_source": r.ProfileSource, - }). - WithRemediation( - output.Remediation{ - Action: "show-container-profile", - Hint: "Check which container profile is default", - Command: "one configure current container/docker", - }, - output.Remediation{ - Action: "list-container-profiles", - Hint: "List configured container profiles", - Command: "one configure list container/docker", - }, - output.Remediation{ - Action: "update-container-profile", - Hint: "Update registry credentials or switch to the right registry", - Command: "one configure add container/docker --profile --registry --username --password --use", - }, - ) - } - return nil -} - -func dockerImageExists(imageTag string) error { - if _, err := exec.LookPath("docker"); err != nil { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - "docker 二进制不在 PATH 中;请安装 Docker Desktop / Engine") - } - c := exec.Command("docker", "image", "inspect", imageTag) - c.Stdout = io.Discard - c.Stderr = io.Discard - if err := c.Run(); err != nil { - return err - } - return nil -} - -func dockerTag(source, target string) error { - if _, err := exec.LookPath("docker"); err != nil { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - "docker 二进制不在 PATH 中;请安装 Docker Desktop / Engine") - } - c := exec.Command("docker", "tag", source, target) - c.Stdout = os.Stdout - c.Stderr = os.Stderr - if err := c.Run(); err != nil { - return cliErrors.New(cliErrors.BACKEND_INVOKE_FAILED, - fmt.Sprintf("docker tag %s %s failed: %s", source, target, err.Error())). - WithContext(map[string]any{ - "source_image": source, - "target_image": target, - }) - } - return nil -} - -func firstNonEmptyLine(values ...string) string { - for _, value := range values { - for _, line := range strings.Split(strings.TrimSpace(value), "\n") { - line = strings.TrimSpace(line) - if line != "" { - return line - } - } - } - return "" -} diff --git a/packages/cli/internal/adapters/container/docker/push.go b/packages/cli/internal/adapters/container/docker/push.go deleted file mode 100644 index db31406d..00000000 --- a/packages/cli/internal/adapters/container/docker/push.go +++ /dev/null @@ -1,169 +0,0 @@ -package docker - -// push.go: `one container push` execution. Resolves the same -// registry-prefixed tag the matching Build would produce, optionally -// auto-retags from a local bare image, runs `docker login` once when -// credentials are present, then shells out to `docker push`. - -import ( - "context" - "fmt" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" -) - -// Push runs `docker push ` for one or all projects after -// resolving the same registry-prefixed tag the matching Build would -// produce. Requires in.Registry.Registry to be set (no point pushing -// to a bare `:` tag) and runs `docker login` first -// when credentials are present. -func Push(ctx context.Context, in container.PushInput) (*container.PushResult, error) { - _ = ctx - if in.Registry == nil || in.Registry.Registry == "" { - project := strings.TrimSpace(in.Project) - buildCommand := "one container build" - setupCommand := "one configure add container/docker --profile --use" - if project != "" { - buildCommand += " " + project - } - return nil, cliErrors.New(cliErrors.REGISTRY_CREDENTIAL_MISSING, - "还没有配置镜像仓库,无法推送镜像。只需要本地使用时执行 `one container build`;需要上传镜像时先执行 `one configure add container/docker --profile --use`。"). - WithContext(map[string]any{ - "project": project, - }). - WithRemediation( - output.Remediation{ - Action: "build-local", - Hint: "只需要本地镜像时,不需要 push", - Command: buildCommand, - }, - output.Remediation{ - Action: "setup-registry", - Hint: "需要上传镜像时,先配置镜像仓库", - Command: setupCommand, - }, - ) - } - m, err := workspace.ReadManifest(in.ProjectRoot) - if err != nil { - return nil, err - } - targets := targetNameSet(in.TargetNames) - res := &container.PushResult{Schema: SchemaPush} - matched := false - loggedIn := false - for _, s := range m.Projects { - if len(targets) > 0 { - if _, ok := targets[s.Name]; !ok { - continue - } - } - if in.Project != "" && s.Name != in.Project { - continue - } - matched = true - imageTag := strings.TrimSpace(in.Tag) - if imageTag != "" { - pushProject := s - if c := containerOverride(s); c != nil { - override := *c - override.Image = "" - domains := *s.Domains - domains.Container = &override - pushProject.Domains = &domains - } - imageTag = imageTagFor(pushProject, in.Registry, imageTag) - } else { - imageTag = imageTagFor(s, in.Registry, "") - } - argv := []string{"docker", "push", imageTag} - entry := container.PushEntry{ - Project: s.Name, - Image: imageTag, - Argv: argv, - DryRun: in.DryRun, - } - if localImage := localImageTagForPush(s, imageTag, in.Tag); localImage != "" && localImage != imageTag { - entry.SourceImage = localImage - entry.Retagged = true - } - if in.DryRun { - res.Pushed = append(res.Pushed, entry) - continue - } - if err := dockerImageExists(imageTag); err != nil { - localImage := entry.SourceImage - if localImage == "" || localImage == imageTag || dockerImageExists(localImage) != nil { - buildCommand := "one container build" - if s.Name != "" { - buildCommand += " " + s.Name - } - return nil, cliErrors.New(cliErrors.IMAGE_TAG_NOT_FOUND, - fmt.Sprintf("本地没有要推送的镜像 %q,也找不到可自动打 tag 的本地镜像。", imageTag)). - WithContext(map[string]any{ - "image": imageTag, - "local_image": localImage, - "project": s.Name, - }). - WithRemediation(output.Remediation{ - Action: "build-image", - Hint: "先构建本地镜像;push 会自动打 registry tag", - Command: buildCommand, - }) - } - if err := dockerTag(localImage, imageTag); err != nil { - return nil, err - } - entry.SourceImage = localImage - entry.Retagged = true - } - if !loggedIn && in.Registry.HasCredentials() { - if err := dockerLogin(in.Registry); err != nil { - return nil, err - } - loggedIn = true - } - if err := process.RunExternal(in.ProjectRoot, argv, "请安装 Docker Desktop / Engine"); err != nil { - return nil, err - } - res.Pushed = append(res.Pushed, entry) - } - if !matched && in.Project != "" { - return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, - fmt.Sprintf("没有名为 %s 的项目", in.Project)) - } - return res, nil -} - -// localImageTagForPush computes the local `:` reference -// to look up + auto-retag from when the target push image isn't -// already present in the local docker daemon. Mirrors Build's tag -// composition (sans registry prefix) so `one container build` then -// `one container push` works as a pipeline even when build skipped -// the registry-prefixed tag (rare but allowed). -func localImageTagForPush(s workspace.ManifestProject, targetImage, explicitTag string) string { - if c := containerOverride(s); c != nil { - override := strings.TrimSpace(c.Image) - if override != "" && !strings.Contains(override, "/") { - return override - } - } - tag := strings.TrimSpace(explicitTag) - if tag == "" { - tag = imageTagVersion(targetImage) - } - localSubproject := s - if c := containerOverride(s); c != nil { - override := *c - override.Image = "" - domains := *s.Domains - domains.Container = &override - localSubproject.Domains = &domains - } - return imageTagFor(localSubproject, nil, tag) -} diff --git a/packages/cli/internal/adapters/container/docker/resolve.go b/packages/cli/internal/adapters/container/docker/resolve.go deleted file mode 100644 index a82cbe6c..00000000 --- a/packages/cli/internal/adapters/container/docker/resolve.go +++ /dev/null @@ -1,231 +0,0 @@ -package docker - -// resolve.go folds a machine-level container/ profile + the -// per-project manifest overrides into a single container.Registry the -// build / push helpers can consume. This is the only file in the -// docker package that knows the four kinds (docker / dockerhub / ghcr -// / acr) differ at all — Build / Push / Info treat the resolved -// Registry uniformly. - -import ( - "fmt" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// ResolveRegistryInput addresses ResolveRegistry. RequireRegistry asks -// for an error when no profile is configured (push / kustomize need -// the registry; bare `one container build` for local-only use can -// tolerate a nil result and skip the prefix). -type ResolveRegistryInput struct { - ProjectRoot string - Kind string - ProfileFlag string - Subproject string - Environment string - RequireRegistry bool - SkipDefault bool -} - -// ResolveRegistry returns the populated container.Registry for one -// (kind, subproject) pair, or nil when RequireRegistry=false and no -// profile is configured (legacy `one container build` falls back to -// local-only mode in that case). On RequireRegistry=true a missing -// profile yields cliErrors.REGISTRY_CREDENTIAL_MISSING with a -// kind-specific remediation. -// -// Each kind's host / namespace derivation is the only real difference -// between the four backends; everything else (Build / Push argv, -// docker login, image-tag composition) is shared. -func ResolveRegistry(in ResolveRegistryInput) (*container.Registry, error) { - kind := strings.TrimSpace(in.Kind) - if kind == "" { - kind = catalog.ContainerDocker - } - if !profile.IsContainerKind(kind) { - return nil, cliErrors.New(cliErrors.CONTAINER_KIND_UNKNOWN, - fmt.Sprintf("不认识的 container kind %q;支持的 kind:%s", - kind, strings.Join(profile.ContainerKinds(), " / "))). - WithContext(map[string]any{ - "kind": kind, - "supported_kinds": profile.ContainerKinds(), - }) - } - - workspaceID := "" - environment := strings.TrimSpace(in.Environment) - if m, err := workspace.ReadManifest(in.ProjectRoot); err == nil { - workspaceID = workspace.WorkspaceID(m) - if environment == "" { - environment = defaultProfileEnvironment(m) - } else { - environment = workspace.ProfileBindingEnvironment(m, environment) - } - } - - resolved, err := profile.Resolve(profile.ResolveInput{ - Domain: profile.DomainContainer, - Backend: kind, - FlagOverride: in.ProfileFlag, - WorkspaceID: workspaceID, - WorkspaceRoot: in.ProjectRoot, - Environment: environment, - ProjectName: in.Subproject, - SkipDefault: in.SkipDefault, - }) - if err != nil { - if cliErr, ok := err.(interface{ ErrorCode() string }); ok && - cliErr.ErrorCode() == "PROFILE_NONE_CONFIGURED" { - if !in.RequireRegistry { - return nil, nil - } - return nil, profileNotConfiguredErr(kind, in.Subproject) - } - return nil, err - } - if resolved.Profile.Container == nil { - return nil, profileInvalidErr(kind, "container profile missing") - } - cp := resolved.Profile.Container - - host, err := hostForKind(kind, cp) - if err != nil { - return nil, err - } - - username := "" - password := "" - if cp.Credentials != nil { - username = cp.Credentials.Username - password = cp.Credentials.Password - } - namespace := strings.TrimSpace(cp.Namespace) - if namespace == "" { - namespace = defaultNamespaceForKind(kind, cp.Registry, username) - } - return &container.Registry{ - Registry: host, - Namespace: namespace, - Username: username, - Password: password, - ProfileName: resolved.Name, - ProfileSource: resolved.Source, - }, nil -} - -func defaultProfileEnvironment(manifest *workspace.Manifest) string { - if manifest == nil || manifest.Environments == nil { - return "" - } - if value := strings.TrimSpace(manifest.Environments.Default); value != "" { - return value - } - for _, candidate := range manifest.Environments.Names { - if value := strings.TrimSpace(candidate); value != "" { - return value - } - } - return "" -} - -// hostForKind derives the registry host string for one kind. docker -// reads it straight from the profile; dockerhub / ghcr have fixed -// hosts; acr (Aliyun) derives from Region. -func hostForKind(kind string, cp *profile.ContainerProfile) (string, error) { - switch kind { - case catalog.ContainerDocker: - host := strings.TrimSpace(cp.Registry) - if host == "" { - return "", profileInvalidErr(kind, "container/docker profile 缺 registry 字段") - } - // Strip an accidental scheme — docker push wants bare host. - host = strings.TrimPrefix(host, "https://") - host = strings.TrimPrefix(host, "http://") - return host, nil - case catalog.ContainerDockerHub: - return "index.docker.io", nil - case catalog.ContainerGHCR: - return "ghcr.io", nil - case catalog.ContainerACR: - region := strings.TrimSpace(cp.Region) - if region == "" { - return "", profileInvalidErr(kind, "container/acr profile 缺 region 字段(host 派生自 registry..aliyuncs.com)") - } - return "registry." + region + ".aliyuncs.com", nil - } - return "", cliErrors.New(cliErrors.CONTAINER_KIND_UNKNOWN, - fmt.Sprintf("hostForKind: unhandled kind %q", kind)) -} - -// defaultNamespaceForKind returns the namespace fallback when neither -// the manifest nor the profile set one. dockerhub / ghcr conventionally -// use username as the namespace prefix; acr requires explicit; the -// docker (generic) kind reuses the legacy host-based heuristic from -// configurecmd / kustomize. -func defaultNamespaceForKind(kind, registry, username string) string { - switch kind { - case catalog.ContainerDockerHub, catalog.ContainerGHCR: - return strings.TrimSpace(username) - case catalog.ContainerACR: - return "" - case catalog.ContainerDocker: - return defaultRegistryNamespace(registry, username) - } - return "" -} - -// defaultRegistryNamespace is the historical heuristic for the docker -// (generic) kind: when the user pointed the profile at a known shared -// registry that uses owner/image naming (ghcr.io / docker.io), default -// the namespace to the configured username. Anything else gets an -// empty namespace so the image lives at /:. -// -// Living here so containercmd / kustomize can delete their duplicate -// copies in Phase F / G; the canonical source of truth is now -// docker.ResolveRegistry. -func defaultRegistryNamespace(registry, username string) string { - registry = strings.TrimSpace(strings.TrimPrefix(registry, "https://")) - registry = strings.TrimPrefix(registry, "http://") - username = strings.TrimSpace(username) - switch registry { - case "ghcr.io", "docker.io", "index.docker.io": - return username - } - return "" -} - -func profileNotConfiguredErr(kind, subproject string) error { - setupCommand := "one configure add container/" + kind + " --profile --use" - return cliErrors.New(cliErrors.REGISTRY_CREDENTIAL_MISSING, - fmt.Sprintf("container/%s 还没有配置 profile。先执行 `%s`。", kind, setupCommand)). - WithContext(map[string]any{ - "kind": kind, - "subproject": strings.TrimSpace(subproject), - }). - WithRemediation(output.Remediation{ - Action: "setup-registry", - Hint: "配置镜像仓库后再构建或推送", - Command: setupCommand, - }) -} - -func profileInvalidErr(kind, detail string) error { - setupCommand := "one configure add container/" + kind + " --profile --use" - return cliErrors.New(cliErrors.CONTAINER_PROFILE_INVALID, - fmt.Sprintf("container/%s profile 不完整:%s。请重新执行 `%s`。", kind, detail, setupCommand)). - WithContext(map[string]any{ - "kind": kind, - "detail": detail, - }). - WithRemediation(output.Remediation{ - Action: "reconfigure-container", - Hint: "重新配置 container profile", - Command: setupCommand, - }) -} diff --git a/packages/cli/internal/adapters/container/docker/resolve_test.go b/packages/cli/internal/adapters/container/docker/resolve_test.go deleted file mode 100644 index 45f096f0..00000000 --- a/packages/cli/internal/adapters/container/docker/resolve_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package docker - -// resolve_test.go locks the host / namespace derivation rules that -// are the only real per-kind difference between docker / dockerhub / -// ghcr / acr. Drift here corrupts the image tag silently — build -// succeeds, push lands at the wrong host — so each path stays pinned. - -import ( - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestHostForKind(t *testing.T) { - tests := []struct { - name string - kind string - cp *profile.ContainerProfile - wantHost string - wantErr bool - wantErrSub string - }{ - { - name: "docker with bare host", - kind: "docker", - cp: &profile.ContainerProfile{Registry: "harbor.example.com"}, - wantHost: "harbor.example.com", - }, - { - name: "docker strips https scheme", - kind: "docker", - cp: &profile.ContainerProfile{Registry: "https://harbor.example.com"}, - wantHost: "harbor.example.com", - }, - { - name: "docker strips http scheme", - kind: "docker", - cp: &profile.ContainerProfile{Registry: "http://harbor.example.com"}, - wantHost: "harbor.example.com", - }, - { - name: "docker requires registry", - kind: "docker", - cp: &profile.ContainerProfile{}, - wantErr: true, - wantErrSub: "registry", - }, - { - name: "dockerhub host is fixed", - kind: "dockerhub", - cp: &profile.ContainerProfile{Registry: "ignored.example.com"}, - wantHost: "index.docker.io", - }, - { - name: "ghcr host is fixed", - kind: "ghcr", - cp: &profile.ContainerProfile{}, - wantHost: "ghcr.io", - }, - { - name: "acr derives from region", - kind: "acr", - cp: &profile.ContainerProfile{Region: "cn-hangzhou"}, - wantHost: "registry.cn-hangzhou.aliyuncs.com", - }, - { - name: "acr region trims whitespace", - kind: "acr", - cp: &profile.ContainerProfile{Region: " cn-shanghai "}, - wantHost: "registry.cn-shanghai.aliyuncs.com", - }, - { - name: "acr requires region", - kind: "acr", - cp: &profile.ContainerProfile{}, - wantErr: true, - wantErrSub: "region", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - host, err := hostForKind(tt.kind, tt.cp) - if tt.wantErr { - if err == nil { - t.Fatalf("hostForKind(%q): expected error, got host=%q", tt.kind, host) - } - if tt.wantErrSub != "" && !contains(err.Error(), tt.wantErrSub) { - t.Errorf("hostForKind(%q): error %q does not mention %q", tt.kind, err.Error(), tt.wantErrSub) - } - return - } - if err != nil { - t.Fatalf("hostForKind(%q): unexpected error: %v", tt.kind, err) - } - if host != tt.wantHost { - t.Errorf("hostForKind(%q): got %q, want %q", tt.kind, host, tt.wantHost) - } - }) - } -} - -func TestDefaultNamespaceForKind(t *testing.T) { - tests := []struct { - name string - kind string - registry string - username string - want string - }{ - {"dockerhub defaults to username", "dockerhub", "", "alice", "alice"}, - {"dockerhub empty username", "dockerhub", "", "", ""}, - {"ghcr defaults to username", "ghcr", "", "bob", "bob"}, - {"ghcr empty username", "ghcr", "", "", ""}, - {"acr never defaults", "acr", "", "carol", ""}, - {"docker (generic) known host docker.io", "docker", "docker.io", "dave", "dave"}, - {"docker (generic) known host ghcr.io", "docker", "ghcr.io", "dave", "dave"}, - {"docker (generic) private host", "docker", "harbor.example.com", "dave", ""}, - {"docker (generic) scheme-prefixed", "docker", "https://ghcr.io", "dave", "dave"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := defaultNamespaceForKind(tt.kind, tt.registry, tt.username) - if got != tt.want { - t.Errorf("defaultNamespaceForKind(%q, %q, %q): got %q, want %q", - tt.kind, tt.registry, tt.username, got, tt.want) - } - }) - } -} - -func TestResolveRegistry_UnknownKind(t *testing.T) { - _, err := ResolveRegistry(ResolveRegistryInput{ - ProjectRoot: t.TempDir(), - Kind: "totally-not-a-kind", - }) - if err == nil { - t.Fatalf("expected CONTAINER_KIND_UNKNOWN error, got nil") - } - if !contains(err.Error(), "totally-not-a-kind") { - t.Errorf("error should mention the unknown kind, got: %v", err) - } -} - -func TestResolveRegistryUsesEnvironmentProjectBinding(t *testing.T) { - configRoot := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", configRoot) - t.Setenv("HOME", configRoot) - root := t.TempDir() - manifest := &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "workspace-id", Name: "demo"}, - Environments: &workspace.Environments{Names: []string{"dev", "prod"}, Default: "dev"}, - Projects: []workspace.ManifestProject{{ - Name: "api", RelativeDir: "services/api", Toolchain: "go", - }}, - } - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - for _, entry := range []struct{ name, username string }{ - {"development", "dev-user"}, {"production", "prod-user"}, - } { - if _, err := profile.Upsert(profile.DomainContainer, "docker", entry.name, profile.Profile{ - Backend: "docker", - Container: &profile.ContainerProfile{ - Registry: "registry.example.com", - Credentials: &profile.ContainerCredentials{ - Username: entry.username, Password: "secret", - }, - }, - }, false); err != nil { - t.Fatal(err) - } - } - if err := profile.BindEnvironmentProfile( - "workspace-id", "demo", root, "api", "prod", - profile.DomainContainer, "docker", "production", - ); err != nil { - t.Fatal(err) - } - - registry, err := ResolveRegistry(ResolveRegistryInput{ - ProjectRoot: root, Kind: "docker", Subproject: "api", Environment: "prod", - RequireRegistry: true, - }) - if err != nil { - t.Fatal(err) - } - if registry.ProfileName != "production" || - registry.ProfileSource != "workspace-project-environment" || - registry.Username != "prod-user" { - t.Fatalf("registry = %#v", registry) - } -} - -func contains(s, sub string) bool { - if len(sub) == 0 { - return true - } - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} diff --git a/packages/cli/internal/adapters/container/docker/sync.go b/packages/cli/internal/adapters/container/docker/sync.go deleted file mode 100644 index 304078f6..00000000 --- a/packages/cli/internal/adapters/container/docker/sync.go +++ /dev/null @@ -1,33 +0,0 @@ -package docker - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - - "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" -) - -// ShouldSync reports whether a Sync call would do work for the given -// subproject. Requires a toolchain Adapter and that no Dockerfile is -// already present (we never overwrite). -func ShouldSync(targetDir string, adapter toolchain.Adapter) bool { - if adapter == nil { - return false - } - _, err := os.Stat(filepath.Join(targetDir, "Dockerfile")) - return errors.Is(err, fs.ErrNotExist) -} - -// Sync renders the Dockerfile via the toolchain adapter and writes it -// into the subproject directory. Caller should gate on ShouldSync to -// avoid clobbering hand-edited Dockerfiles. -func Sync(targetDir string, adapter toolchain.Adapter, pm toolchain.PackageManager, runtime toolchain.RuntimeResolution) error { - content := adapter.RenderDockerfile(toolchain.DockerfileInput{ - PackageManager: pm, - Runtime: runtime, - }) - path := filepath.Join(targetDir, "Dockerfile") - return os.WriteFile(path, []byte(content), 0o644) -} diff --git a/packages/cli/internal/adapters/container/docker/version.go b/packages/cli/internal/adapters/container/docker/version.go deleted file mode 100644 index c00f2255..00000000 --- a/packages/cli/internal/adapters/container/docker/version.go +++ /dev/null @@ -1,73 +0,0 @@ -package docker - -// version.go: fall-back chain for the image-tag version when the -// caller doesn't pass `--build-version`. Order: -// manifest.buildVersion → `git describe --tags --exact-match HEAD` -// → package.json#version (subproject) → package.json#version (root) -// → workspace.DefaultBuildVersion -// -// Exposed via DefaultImageVersion so containercmd can decide whether -// an interactive prompt is necessary before invoking Build. - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func defaultImageVersion(projectRoot, projectDir, projectName string) (string, error) { - if m, err := workspace.ReadManifest(projectRoot); err == nil { - if v := workspace.BuildVersionForProject(m, projectName); v != "" { - return workspace.BuildTagForVersion(v), nil - } - } - if v := gitExactTag(projectDir); v != "" { - return v, nil - } - if v := packageVersion(projectDir); v != "" { - return v, nil - } - if v := packageVersion(projectRoot); v != "" { - return v, nil - } - return workspace.BuildTagForVersion(workspace.DefaultBuildVersion), nil -} - -// DefaultImageVersion exposes the build tag fallback chain for callers -// that need to decide whether an interactive tag prompt is necessary -// before invoking Build. -func DefaultImageVersion(projectRoot, projectDir, projectName string) (string, error) { - return defaultImageVersion(projectRoot, projectDir, projectName) -} - -func gitExactTag(projectRoot string) string { - c := exec.Command("git", "describe", "--tags", "--exact-match", "HEAD") - c.Dir = projectRoot - out, err := c.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -func packageVersion(dir string) string { - raw, err := os.ReadFile(filepath.Join(dir, "package.json")) - if err != nil { - return "" - } - var doc struct { - Version string `json:"version"` - } - if err := json.Unmarshal(raw, &doc); err != nil { - return "" - } - version := strings.TrimSpace(doc.Version) - if version == "" || version == "0.0.0" { - return "" - } - return version -} diff --git a/packages/cli/internal/adapters/deploy/build/local.go b/packages/cli/internal/adapters/deploy/build/local.go deleted file mode 100644 index cf82e599..00000000 --- a/packages/cli/internal/adapters/deploy/build/local.go +++ /dev/null @@ -1,126 +0,0 @@ -// Package build implements local pre-deploy project builds. -package build - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/prompt" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" -) - -// Local runs build commands on the current machine. -type Local struct{} - -func (Local) Build(ctx context.Context, input deployport.BuildInput) ([]string, error) { - if !shouldAutoBuild(input) { - return nil, nil - } - projectDir := projectDirectory(input.Apply) - scripts, err := readPackageScripts(projectDir) - if err != nil { - return nil, err - } - if _, ok := scripts["build"]; !ok { - return nil, nil - } - argv := nodeBuildArgv(input.PackageManager) - line := strings.Join(argv, " ") - if input.Apply.DryRun { - return []string{line}, nil - } - return nil, prompt.Spin(fmt.Sprintf("正在构建项目 %s", input.Apply.Project.Name), func() error { - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Dir = projectDir - cmd.Stdout = input.Apply.Stdout - cmd.Stderr = input.Apply.Stderr - cmd.Env = augmentBuildEnv( - os.Environ(), input.Apply.ProjectRoot, projectDir, input.Apply.InjectedEnv, - ) - return cmd.Run() - }) -} - -func shouldAutoBuild(input deployport.BuildInput) bool { - if input.Toolchain != "node" { - return false - } - switch input.Backend { - case workspace.DeployBackendCloudflare, workspace.DeployBackendEdgeOne: - return true - default: - return false - } -} - -func projectDirectory(input deployport.ApplyInput) string { - if input.Project.TargetDir != "" { - return input.Project.TargetDir - } - return filepath.Join(input.ProjectRoot, filepath.FromSlash(input.Project.RelativeDir)) -} - -func readPackageScripts(projectDir string) (map[string]string, error) { - raw, err := os.ReadFile(filepath.Join(projectDir, "package.json")) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var pkg struct { - Scripts map[string]string `json:"scripts"` - } - if err := json.Unmarshal(raw, &pkg); err != nil { - return nil, err - } - return pkg.Scripts, nil -} - -func nodeBuildArgv(packageManager string) []string { - switch strings.TrimSpace(packageManager) { - case "npm": - return []string{"npm", "run", "build"} - case "yarn": - return []string{"yarn", "build"} - default: - return []string{"pnpm", "run", "build"} - } -} - -func augmentBuildEnv(parent []string, projectRoot, projectDir string, injected map[string]string) []string { - base := secrets.MergeIntoEnviron(parent, injected, true) - binPaths := []string{ - filepath.Join(projectDir, "node_modules", ".bin"), - filepath.Join(projectRoot, "node_modules", ".bin"), - } - separator := string(os.PathListSeparator) - out := make([]string, 0, len(base)+1) - replaced := false - for _, value := range base { - key, existing, found := strings.Cut(value, "=") - isPath := key == "PATH" || (runtime.GOOS == "windows" && strings.EqualFold(key, "PATH")) - if !replaced && found && isPath { - parts := append([]string{}, binPaths...) - if existing != "" { - parts = append(parts, existing) - } - out = append(out, "PATH="+strings.Join(parts, separator)) - replaced = true - continue - } - out = append(out, value) - } - if !replaced { - out = append(out, "PATH="+strings.Join(binPaths, separator)) - } - return out -} diff --git a/packages/cli/internal/adapters/deploy/build/local_test.go b/packages/cli/internal/adapters/deploy/build/local_test.go deleted file mode 100644 index 63af3315..00000000 --- a/packages/cli/internal/adapters/deploy/build/local_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package build - -import ( - "context" - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -func TestLocalDryRunPlansNodeBuild(t *testing.T) { - projectDir := t.TempDir() - if err := os.WriteFile( - filepath.Join(projectDir, "package.json"), - []byte(`{"scripts":{"build":"vite build"}}`), - 0o600, - ); err != nil { - t.Fatal(err) - } - - lines, err := (Local{}).Build(context.Background(), deployport.BuildInput{ - Apply: deployport.ApplyInput{ - ProjectRoot: projectDir, - Project: workspace.Project{Name: "web", TargetDir: projectDir}, - DryRun: true, - }, - Backend: workspace.DeployBackendCloudflare, - Toolchain: "node", - PackageManager: "npm", - }) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(lines, []string{"npm run build"}) { - t.Fatalf("Build() = %#v", lines) - } -} - -func TestLocalSkipsBackendsWithoutAutoBuild(t *testing.T) { - lines, err := (Local{}).Build(context.Background(), deployport.BuildInput{ - Apply: deployport.ApplyInput{DryRun: true}, - Backend: workspace.DeployBackendVercel, - Toolchain: "node", - }) - if err != nil || lines != nil { - t.Fatalf("Build() = %#v, %v", lines, err) - } -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/config.go b/packages/cli/internal/adapters/deploy/cloudflare/config.go deleted file mode 100644 index 88829255..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/config.go +++ /dev/null @@ -1,70 +0,0 @@ -package cloudflare - -import ( - "encoding/json" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// ProjectConfig is the typed view over -// `projects[i].domains.deploy.config` when the per-project deploy backend -// is Cloudflare. WorkerName mirrors wrangler.toml#name (the deploy slug -// visible in dash.cloudflare.com); empty defers to whatever wrangler.toml -// or CLOUDFLARE_WORKER_NAME env var says. Env names the deploy target -// environment (drawn from manifest.environments.names; empty / "prod" → -// production deploy, anything else → wrangler named environment). -type ProjectConfig struct { - WorkerName string `json:"workerName,omitempty"` - Env string `json:"env,omitempty"` -} - -// DecodeProjectConfig pulls the Cloudflare-specific config blob out of -// the manifest's per-project deploy section. Returns (nil, nil) when no -// project with that name has a Cloudflare deploy section configured. -func DecodeProjectConfig(m *workspace.Manifest, projectName string) (*ProjectConfig, error) { - if m == nil { - return nil, nil - } - for _, p := range m.Projects { - if p.Name != projectName { - continue - } - if p.Domains == nil || p.Domains.Deploy == nil || p.Domains.Deploy.Kind != workspace.DeployBackendCloudflare { - return nil, nil - } - if len(p.Domains.Deploy.Config) == 0 { - return &ProjectConfig{}, nil - } - var cfg ProjectConfig - if err := json.Unmarshal(p.Domains.Deploy.Config, &cfg); err != nil { - return nil, err - } - return &cfg, nil - } - return nil, nil -} - -// EncodeProjectConfig writes cfg back into projects[i].domains.deploy.config -// (in memory), creating the deploy section if necessary. Caller persists -// via WriteManifest. -func EncodeProjectConfig(p *workspace.ManifestProject, cfg *ProjectConfig) error { - if p == nil { - return nil - } - if p.Domains == nil { - p.Domains = &workspace.ProjectDomains{} - } - if p.Domains.Deploy == nil { - p.Domains.Deploy = &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendCloudflare} - } - if cfg == nil { - p.Domains.Deploy.Config = nil - return nil - } - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - p.Domains.Deploy.Config = raw - return nil -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/d1_preflight.go b/packages/cli/internal/adapters/deploy/cloudflare/d1_preflight.go deleted file mode 100644 index 1e7e9444..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/d1_preflight.go +++ /dev/null @@ -1,256 +0,0 @@ -package cloudflare - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "github.com/pelletier/go-toml/v2" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -var ( - cloudflareAPIBaseURL = "https://api.cloudflare.com/client/v4" - cloudflareHTTPClient = &http.Client{Timeout: 30 * time.Second} -) - -type wranglerD1Config struct { - D1Databases []d1DatabaseBinding `toml:"d1_databases"` -} - -type d1DatabaseBinding struct { - Binding string `toml:"binding"` - DatabaseName string `toml:"database_name"` - DatabaseID string `toml:"database_id"` -} - -type cloudflareEnvelope[T any] struct { - Success bool `json:"success"` - Errors []cloudflareAPIResponse `json:"errors"` - Result T `json:"result"` -} - -type cloudflareAPIResponse struct { - Code int `json:"code"` - Message string `json:"message"` -} - -type cloudflareAccount struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type cloudflareD1Database struct { - UUID string `json:"uuid"` - Name string `json:"name"` -} - -func preflightD1DatabaseBindings(ctx context.Context, projectDir, apiToken, accountID string) error { - bindings, err := readD1DatabaseBindings(projectDir) - if err != nil { - return err - } - if len(bindings) == 0 { - return nil - } - accountID, err = resolveD1PreflightAccountID(ctx, apiToken, accountID) - if err != nil { - return err - } - for _, binding := range bindings { - if err := validateD1BindingShape(binding); err != nil { - return err - } - db, err := fetchD1Database(ctx, accountID, binding.DatabaseID, apiToken) - if err != nil { - return d1BindingError( - fmt.Sprintf("D1 binding %q 指向的 database_id 无法在 Cloudflare 中确认。", binding.Binding), - binding, - map[string]any{"account_id": accountID, "api_error": err.Error()}, - ) - } - if db.Name != "" && binding.DatabaseName != "" && db.Name != binding.DatabaseName { - return d1BindingError( - fmt.Sprintf("D1 binding %q 的 database_name 与 Cloudflare 中的数据库不一致。", binding.Binding), - binding, - map[string]any{ - "account_id": accountID, - "actual_database_name": db.Name, - }, - ) - } - } - return nil -} - -func readD1DatabaseBindings(projectDir string) ([]d1DatabaseBinding, error) { - raw, err := os.ReadFile(filepath.Join(projectDir, WranglerConfigFilename)) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var cfg wranglerD1Config - if err := toml.Unmarshal(raw, &cfg); err != nil { - return nil, cliErrors.New(cliErrors.CLOUDFLARE_DEPLOY_FAILED, - "wrangler.toml 解析失败,无法校验 Cloudflare D1 binding。"). - WithContext(map[string]any{"project_dir": projectDir, "parse_error": err.Error()}). - WithRemediation(output.Remediation{ - Action: "fix-wrangler-toml", - Hint: "检查 wrangler.toml 语法,特别是 [[d1_databases]] 块", - }) - } - return cfg.D1Databases, nil -} - -func resolveD1PreflightAccountID(ctx context.Context, apiToken, accountID string) (string, error) { - accountID = strings.TrimSpace(accountID) - if accountID != "" { - return accountID, nil - } - accounts, err := listCloudflareAccounts(ctx, apiToken) - if err != nil { - return "", cliErrors.New(cliErrors.CLOUDFLARE_PROFILE_INVALID, - "wrangler.toml 配置了 D1,但 Cloudflare profile 没有 accountId,且 One CLI 无法自动解析账号。"). - WithContext(map[string]any{"api_error": err.Error()}). - WithRemediation(output.Remediation{ - Action: "set-account-id", - Hint: "给 Cloudflare profile 补上 Account ID 后重试", - Command: "one configure add deploy/cloudflare cf-prod --use --account-id ", - }) - } - if len(accounts) != 1 { - return "", cliErrors.New(cliErrors.CLOUDFLARE_PROFILE_INVALID, - "wrangler.toml 配置了 D1,但当前 token 没有唯一 Cloudflare account 可用于校验。"). - WithContext(map[string]any{"accounts_count": len(accounts)}). - WithRemediation(output.Remediation{ - Action: "set-account-id", - Hint: "多账号或无法自动判断账号时,需要在 Cloudflare profile 里写入 Account ID", - Command: "one configure add deploy/cloudflare cf-prod --use --account-id ", - }) - } - return accounts[0].ID, nil -} - -func validateD1BindingShape(binding d1DatabaseBinding) error { - missing := make([]string, 0, 3) - if strings.TrimSpace(binding.Binding) == "" { - missing = append(missing, "binding") - } - if strings.TrimSpace(binding.DatabaseName) == "" { - missing = append(missing, "database_name") - } - if strings.TrimSpace(binding.DatabaseID) == "" { - missing = append(missing, "database_id") - } - if len(missing) == 0 { - return nil - } - return d1BindingError("wrangler.toml 的 D1 binding 缺少必要字段。", binding, map[string]any{"missing_fields": missing}) -} - -func listCloudflareAccounts(ctx context.Context, apiToken string) ([]cloudflareAccount, error) { - endpoint := cloudflareEndpoint("/accounts") - q := endpoint.Query() - q.Set("per_page", "50") - endpoint.RawQuery = q.Encode() - var env cloudflareEnvelope[[]cloudflareAccount] - if err := cloudflareGet(ctx, endpoint.String(), apiToken, &env); err != nil { - return nil, err - } - return env.Result, nil -} - -func fetchD1Database(ctx context.Context, accountID, databaseID, apiToken string) (cloudflareD1Database, error) { - path := fmt.Sprintf("/accounts/%s/d1/database/%s", url.PathEscape(accountID), url.PathEscape(databaseID)) - var env cloudflareEnvelope[cloudflareD1Database] - if err := cloudflareGet(ctx, cloudflareEndpoint(path).String(), apiToken, &env); err != nil { - return cloudflareD1Database{}, err - } - if env.Result.UUID == "" { - env.Result.UUID = databaseID - } - return env.Result, nil -} - -func cloudflareGet(ctx context.Context, endpoint, apiToken string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+apiToken) - resp, err := cloudflareHTTPClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - var raw cloudflareEnvelope[json.RawMessage] - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 || !raw.Success { - return fmt.Errorf("cloudflare api %s returned status %d: %s", endpoint, resp.StatusCode, cloudflareErrorMessages(raw.Errors)) - } - if out == nil { - return nil - } - b, err := json.Marshal(raw) - if err != nil { - return err - } - return json.Unmarshal(b, out) -} - -func cloudflareEndpoint(path string) *url.URL { - endpoint, _ := url.Parse(strings.TrimRight(cloudflareAPIBaseURL, "/") + "/" + strings.TrimLeft(path, "/")) - return endpoint -} - -func cloudflareErrorMessages(errors []cloudflareAPIResponse) string { - if len(errors) == 0 { - return "unknown error" - } - msgs := make([]string, 0, len(errors)) - for _, item := range errors { - if item.Message != "" { - msgs = append(msgs, item.Message) - } - } - if len(msgs) == 0 { - return "unknown error" - } - return strings.Join(msgs, "; ") -} - -func d1BindingError(message string, binding d1DatabaseBinding, extra map[string]any) *output.Error { - ctx := map[string]any{ - "binding": binding.Binding, - "database_name": binding.DatabaseName, - "database_id": binding.DatabaseID, - } - for k, v := range extra { - ctx[k] = v - } - return cliErrors.New(cliErrors.CLOUDFLARE_DEPLOY_FAILED, message). - WithContext(ctx). - WithRemediation( - output.Remediation{ - Action: "check-d1-database-id", - Hint: "确认 wrangler.toml 里的 database_id 来自当前 Cloudflare account 下的 D1 数据库", - }, - output.Remediation{ - Action: "list-d1-databases", - Hint: "查看当前账号下的 D1 数据库 ID", - Command: "pnpm exec wrangler d1 list", - }, - ) -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/d1_preflight_test.go b/packages/cli/internal/adapters/deploy/cloudflare/d1_preflight_test.go deleted file mode 100644 index cc717070..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/d1_preflight_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package cloudflare - -import ( - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -func TestPreflightD1DatabaseBindingsPassesExistingDatabase(t *testing.T) { - dir := seedD1WranglerConfig(t, `db-ok`, `my-db`) - withCloudflareAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/client/v4/accounts/acct/d1/database/db-ok" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer tok" { - t.Fatalf("Authorization = %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"result":{"uuid":"db-ok","name":"my-db"},"errors":[]}`)) - })) - - if err := preflightD1DatabaseBindings(context.Background(), dir, "tok", "acct"); err != nil { - t.Fatalf("preflightD1DatabaseBindings: %v", err) - } -} - -func TestPreflightD1DatabaseBindingsRejectsMissingDatabase(t *testing.T) { - dir := seedD1WranglerConfig(t, `wrong-id`, `my-db`) - withCloudflareAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"success":false,"result":null,"errors":[{"code":1001,"message":"database not found"}]}`)) - })) - - err := preflightD1DatabaseBindings(context.Background(), dir, "tok", "acct") - if err == nil { - t.Fatal("expected D1 preflight error") - } - outErr, ok := err.(*output.Error) - if !ok { - t.Fatalf("error type = %T, want *output.Error", err) - } - if outErr.Code != "CLOUDFLARE_DEPLOY_FAILED" { - t.Fatalf("code = %s", outErr.Code) - } - if outErr.Context["database_id"] != "wrong-id" { - t.Fatalf("database_id context missing: %v", outErr.Context) - } - if !hasRemediationAction(outErr.Remediation, "check-d1-database-id") { - t.Fatalf("D1 remediation missing: %+v", outErr.Remediation) - } -} - -func TestPreflightD1DatabaseBindingsResolvesSingleAccount(t *testing.T) { - dir := seedD1WranglerConfig(t, `db-ok`, `my-db`) - withCloudflareAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/client/v4/accounts": - _, _ = w.Write([]byte(`{"success":true,"result":[{"id":"acct","name":"prod"}],"errors":[]}`)) - case "/client/v4/accounts/acct/d1/database/db-ok": - _, _ = w.Write([]byte(`{"success":true,"result":{"uuid":"db-ok","name":"my-db"},"errors":[]}`)) - default: - t.Fatalf("unexpected path: %s", r.URL.Path) - } - })) - - if err := preflightD1DatabaseBindings(context.Background(), dir, "tok", ""); err != nil { - t.Fatalf("preflightD1DatabaseBindings: %v", err) - } -} - -func TestApplyBlocksBeforeWranglerWhenD1PreflightFails(t *testing.T) { - tmp := t.TempDir() - projectDir := seedD1WranglerConfigIn(t, filepath.Join(tmp, "project"), `wrong-id`, `my-db`) - logPath := filepath.Join(tmp, "wrangler.log") - installFakeWranglerAt(t, filepath.Join(projectDir, "node_modules", ".bin"), logPath, "https://demo.example.workers.dev") - t.Setenv("PATH", t.TempDir()) - withCloudflareAPIServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"success":false,"result":null,"errors":[{"message":"database not found"}]}`)) - })) - - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: projectDir, - APIToken: "tok", - AccountID: "acct", - Env: "prod", - DryRun: false, - InjectedEnv: nil, - }) - if err == nil { - t.Fatal("expected preflight failure") - } - if raw, readErr := os.ReadFile(logPath); readErr == nil && strings.Contains(string(raw), "argv: deploy") { - t.Fatalf("wrangler should not run after D1 preflight failure:\n%s", string(raw)) - } -} - -func seedD1WranglerConfig(t *testing.T, databaseID, databaseName string) string { - t.Helper() - return seedD1WranglerConfigIn(t, t.TempDir(), databaseID, databaseName) -} - -func seedD1WranglerConfigIn(t *testing.T, dir, databaseID, databaseName string) string { - t.Helper() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir project dir: %v", err) - } - body := `name = "web" -compatibility_date = "2024-09-23" - -[[d1_databases]] -binding = "DB" -database_name = "` + databaseName + `" -database_id = "` + databaseID + `" -` - if err := os.WriteFile(filepath.Join(dir, WranglerConfigFilename), []byte(body), 0o644); err != nil { - t.Fatalf("write wrangler.toml: %v", err) - } - return dir -} - -func withCloudflareAPIServer(t *testing.T, handler http.Handler) { - t.Helper() - prevBaseURL := cloudflareAPIBaseURL - prevClient := cloudflareHTTPClient - srv := httptest.NewServer(handler) - t.Cleanup(func() { - srv.Close() - cloudflareAPIBaseURL = prevBaseURL - cloudflareHTTPClient = prevClient - }) - cloudflareAPIBaseURL = srv.URL + "/client/v4" - cloudflareHTTPClient = srv.Client() -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/ops.go b/packages/cli/internal/adapters/deploy/cloudflare/ops.go deleted file mode 100644 index 9f88f143..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/ops.go +++ /dev/null @@ -1,319 +0,0 @@ -// Package cloudflare implements the deploy/cloudflare backend. The -// provider shells out to wrangler — Cloudflare's official CLI — rather -// than calling Cloudflare's REST API directly: wrangler handles the -// V8 isolate bundling, R2/KV/D1 binding upload, static-asset -// fingerprinting, and Worker version routing for us. Doing that work -// in Go would be net-negative effort. -// -// Layout follows the same shape as adapters/deploy/vercel — ops.go houses argv -// builders + the actual exec call so it stays trivially testable; -// sync.go scaffolds wrangler.toml; provider.go adapts everything onto -// the deploy.Provider interface. Bootstrap registers the provider explicitly. -// -// Auth threading: wrangler reads CLOUDFLARE_API_TOKEN and -// CLOUDFLARE_ACCOUNT_ID from the process environment. We inject them -// via cmd.Env rather than passing on argv so the wire-format envelope -// (and dry-run output) is naturally secret-free — no separate -// masking layer needed. -package cloudflare - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "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/ports/secrets" -) - -// SchemaApply is the JSON envelope schema string for a cloudflare -// deploy. Versioned per-backend to allow Cloudflare-specific shape -// changes without disturbing other envelopes. -const SchemaApply = "one-cli/deploy-apply-cloudflare/v1" - -// CLIBinary is the executable name we look for on $PATH. Exported so -// tests can stub it (the test variant points at a fake binary). -var CLIBinary = "wrangler" - -// envCloudflareAPIToken is the env var wrangler reads for auth. Same -// name as wrangler's documented contract. -const envCloudflareAPIToken = "CLOUDFLARE_API_TOKEN" - -// envCloudflareAccountID is the optional account scope wrangler reads -// when set. Required only on multi-account tokens. -const envCloudflareAccountID = "CLOUDFLARE_ACCOUNT_ID" - -// ApplyInput addresses Apply. -type ApplyInput struct { - // ProjectDir is the absolute working directory wrangler runs in - // (the per-project dir, not the workspace root). wrangler scans - // this dir for wrangler.toml + entry-point auto-detection. - ProjectDir string - - // APIToken is the Cloudflare API token (from the deploy/cloudflare - // profile). Threaded into wrangler via CLOUDFLARE_API_TOKEN env. - APIToken string - - // AccountID is the optional account scope. Threaded into wrangler - // via CLOUDFLARE_ACCOUNT_ID env. Empty leaves the env var unset. - AccountID string - - // Env names the deploy target environment (from manifest.environments.names, - // or the deploy command's --env flag). Empty or "prod" runs a production - // deploy (no --env flag, wrangler's implicit production environment); - // any other value maps directly to `wrangler deploy --env=`, where - // the value must match a [env.] section in wrangler.toml. - Env string - - // DryRun returns the planned argv without invoking wrangler. - DryRun bool - - // InjectedEnv is the project's user-set env vars (resolved by - // deploycmd from dotenv / Infisical). Merged into wrangler's - // child env before the auth env vars, so build-time code (e.g. - // Worker bundling, static-asset transforms) can read them via - // process.env. Auth env vars (CLOUDFLARE_API_TOKEN / - // CLOUDFLARE_ACCOUNT_ID) always win on collision. nil = none. - InjectedEnv map[string]string -} - -// ApplyResult is the JSON envelope emitted on success. -type ApplyResult struct { - Schema string `json:"schema"` - Argv []string `json:"argv"` - CommandLines []string `json:"command_lines,omitempty"` - DryRun bool `json:"dry_run"` - - // DeploymentURL is captured from wrangler output on success. - // Empty in dry-run. - DeploymentURL string `json:"deployment_url,omitempty"` -} - -// Apply runs the wrangler CLI to deploy one project: -// -// wrangler deploy [--env ] -// -// One step, no pull / build separation: wrangler deploy bundles + -// uploads atomically. Auth flows in via CLOUDFLARE_API_TOKEN / -// CLOUDFLARE_ACCOUNT_ID env vars, not flags, so argv stays clean. -func Apply(ctx context.Context, in ApplyInput) (*ApplyResult, error) { - if strings.TrimSpace(in.APIToken) == "" { - return nil, cliErrors.New(cliErrors.CLOUDFLARE_PROFILE_INVALID, - "deploy/cloudflare profile 缺少 API token。先 `one configure add deploy/cloudflare --profile --token --use`。") - } - - deployArgv := buildDeployArgv(in) - commandLines := []string{argvDisplay(deployArgv)} - - if in.DryRun { - return &ApplyResult{ - Schema: SchemaApply, - Argv: deployArgv, - CommandLines: commandLines, - DryRun: true, - }, nil - } - - if err := preflightD1DatabaseBindings(ctx, in.ProjectDir, in.APIToken, in.AccountID); err != nil { - return nil, err - } - - execArgv := deployArgv - binary, err := resolveCLIBinary(in.ProjectDir) - if err != nil { - return nil, err - } - if binary != deployArgv[0] { - execArgv = append([]string{binary}, deployArgv[1:]...) - } - - url, err := runDeployStep(ctx, in.ProjectDir, execArgv, in.APIToken, in.AccountID, in.InjectedEnv) - if err != nil { - return nil, err - } - return &ApplyResult{ - Schema: SchemaApply, - Argv: execArgv, - CommandLines: commandLines, - DryRun: false, - DeploymentURL: url, - }, nil -} - -func resolveCLIBinary(projectDir string) (string, error) { - if path, err := exec.LookPath(CLIBinary); err == nil { - return path, nil - } - for _, candidate := range localCLICandidates(projectDir) { - if isExecutableFile(candidate) { - return candidate, nil - } - } - return "", cliErrors.New(cliErrors.CLOUDFLARE_CLI_MISSING, - "未在 PATH 或项目 node_modules/.bin 中找到 `wrangler` 二进制。安装方式见错误的 remediation。"). - WithContext(map[string]any{"binary": CLIBinary, "project_dir": projectDir}) -} - -func localCLICandidates(projectDir string) []string { - projectDir = strings.TrimSpace(projectDir) - if projectDir == "" { - return nil - } - name := filepath.Base(CLIBinary) - if name == "." || name == string(filepath.Separator) { - name = CLIBinary - } - candidates := []string{filepath.Join(projectDir, "node_modules", ".bin", name)} - if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(name), ".cmd") { - candidates = append(candidates, filepath.Join(projectDir, "node_modules", ".bin", name+".cmd")) - } - return candidates -} - -func isExecutableFile(path string) bool { - info, err := os.Stat(path) - if err != nil || info.IsDir() { - return false - } - if runtime.GOOS == "windows" { - return true - } - return info.Mode().Perm()&0o111 != 0 -} - -func buildDeployArgv(in ApplyInput) []string { - argv := []string{CLIBinary, "deploy"} - if envName := resolveEnvName(in); envName != "" { - argv = append(argv, "--env="+envName) - } - return argv -} - -// resolveEnvName maps the user-facing Env onto wrangler's --env flag. -// Empty or "prod" → no --env (wrangler's implicit production environment). -// Any other value → that value, passed verbatim to `--env=`. -func resolveEnvName(in ApplyInput) string { - env := strings.TrimSpace(in.Env) - if env == "" || env == "prod" { - return "" - } - return env -} - -// argvDisplay joins argv into a copy-pasteable single-line string for -// dry-run / command_lines output. -func argvDisplay(argv []string) string { - return strings.Join(argv, " ") -} - -// runDeployStep streams wrangler stdout/stderr to the user's terminal -// while sniffing for the deployment URL. Wrangler prints the deploy -// URL on its own line, e.g. "Published demo (1.2 sec) https://demo..workers.dev". -func runDeployStep(ctx context.Context, dir string, argv []string, apiToken, accountID string, injected map[string]string) (string, error) { - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Dir = dir - cmd.Stderr = os.Stderr - cmd.Env = wranglerEnv(os.Environ(), injected, apiToken, accountID) - stdout, err := cmd.StdoutPipe() - if err != nil { - return "", err - } - if err := cmd.Start(); err != nil { - return "", err - } - url := captureDeploymentURL(stdout) - if err := cmd.Wait(); err != nil { - d1Configured := hasD1DatabaseBinding(dir) - remediation := []output.Remediation{ - { - Action: "rerun-step", - Hint: "在项目目录手动跑一次 `wrangler deploy`,看完整 wrangler CLI 输出", - }, - } - if d1Configured { - remediation = append(remediation, output.Remediation{ - Action: "verify-d1-binding", - Hint: "wrangler.toml 配置了 D1;确认 database_id 对应的数据库已在当前 Cloudflare account 中创建,且 token 有 D1 Edit 权限", - }) - } - return "", cliErrors.New(cliErrors.CLOUDFLARE_DEPLOY_FAILED, - fmt.Sprintf("`wrangler deploy` 失败:%v", err)). - WithContext(map[string]any{"argv": argv, "d1_binding_configured": d1Configured}). - WithRemediation(remediation...) - } - return url, nil -} - -func hasD1DatabaseBinding(projectDir string) bool { - raw, err := os.ReadFile(filepath.Join(projectDir, "wrangler.toml")) - if err != nil { - return false - } - return strings.Contains(string(raw), "[[d1_databases]]") -} - -// wranglerEnv builds the env wrangler runs under. Order matters: -// -// 1. Merge user-injected env vars on top of the parent shell (override -// mode) so e.g. an .env API_URL beats a stale shell API_URL. -// 2. Strip + re-append the auth env vars so the profile token / -// account always wins, even if a user accidentally set -// CLOUDFLARE_API_TOKEN in their .env. -func wranglerEnv(parent []string, injected map[string]string, apiToken, accountID string) []string { - base := secrets.MergeIntoEnviron(parent, injected, true) - out := make([]string, 0, len(base)+2) - for _, kv := range base { - if strings.HasPrefix(kv, envCloudflareAPIToken+"=") { - continue - } - if strings.HasPrefix(kv, envCloudflareAccountID+"=") { - continue - } - out = append(out, kv) - } - out = append(out, envCloudflareAPIToken+"="+apiToken) - if strings.TrimSpace(accountID) != "" { - out = append(out, envCloudflareAccountID+"="+accountID) - } - return out -} - -// captureDeploymentURL streams the CLI stdout to the user's terminal -// while sniffing for the first https://*.workers.dev or *.pages.dev URL. -// Wrangler emits the deployment URL on a dedicated line at the end of -// `wrangler deploy`. -func captureDeploymentURL(stdout interface{ Read(p []byte) (int, error) }) string { - buf := make([]byte, 4096) - var collected strings.Builder - for { - n, err := stdout.Read(buf) - if n > 0 { - os.Stdout.Write(buf[:n]) - collected.Write(buf[:n]) - } - if err != nil { - break - } - } - for _, line := range strings.Split(collected.String(), "\n") { - // Wrangler prefixes URLs with markers like " https://" — find - // the first https:// substring on each line. - idx := strings.Index(line, "https://") - if idx < 0 { - continue - } - candidate := strings.TrimSpace(line[idx:]) - // Trim trailing punctuation wrangler sometimes appends. - candidate = strings.TrimRight(candidate, ".,;)") - if strings.Contains(candidate, ".workers.dev") || strings.Contains(candidate, ".pages.dev") { - return candidate - } - } - return "" -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/ops_test.go b/packages/cli/internal/adapters/deploy/cloudflare/ops_test.go deleted file mode 100644 index 1da7e0ae..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/ops_test.go +++ /dev/null @@ -1,470 +0,0 @@ -package cloudflare - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// argv builders ---------------------------------------------------------- - -func TestBuildDeployArgvProduction(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "tok-123", - AccountID: "acct", - Env: "prod", - }) - want := []string{CLIBinary, "deploy"} - assertArgv(t, got, want) -} - -// Empty Env defaults to production (no --env flag); preserves the pre-v4 -// behaviour where the unset preview field meant "ship to prod". -func TestBuildDeployArgvEmptyEnvDefaultsToProduction(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "tok-123", - }) - want := []string{CLIBinary, "deploy"} - assertArgv(t, got, want) -} - -func TestBuildDeployArgvNamedEnv(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "tok-123", - Env: "staging", - }) - want := []string{CLIBinary, "deploy", "--env=staging"} - assertArgv(t, got, want) -} - -func TestBuildDeployArgvDevEnv(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "tok-123", - Env: "dev", - }) - want := []string{CLIBinary, "deploy", "--env=dev"} - assertArgv(t, got, want) -} - -// argv must never contain auth material — wrangler reads token / -// account from env, so the argv is a clean public-safe value. - -func TestBuildDeployArgvNeverIncludesToken(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "tok-secret-xyz", - AccountID: "acct-abc", - Env: "prod", - }) - for _, a := range got { - if strings.Contains(a, "tok-secret-xyz") { - t.Fatalf("argv leaked token: %v", got) - } - if strings.Contains(a, "acct-abc") { - t.Fatalf("argv leaked account id: %v", got) - } - } -} - -// wranglerEnv: the token is injected into env, the account id only when -// non-empty, and any pre-existing CLOUDFLARE_API_TOKEN in the parent -// shell is replaced (profile is the source of truth). - -func TestWranglerEnvInjectsToken(t *testing.T) { - parent := []string{"FOO=bar", "PATH=/usr/bin"} - env := wranglerEnv(parent, nil, "tok-1", "") - if !containsString(env, "CLOUDFLARE_API_TOKEN=tok-1") { - t.Fatalf("env missing token: %v", env) - } - if containsPrefix(env, "CLOUDFLARE_ACCOUNT_ID=") { - t.Fatalf("empty account id should not be set: %v", env) - } - // Parent vars preserved. - if !containsString(env, "FOO=bar") { - t.Fatalf("parent env not preserved: %v", env) - } -} - -func TestWranglerEnvInjectsAccountIDWhenSet(t *testing.T) { - env := wranglerEnv(nil, nil, "tok-1", "acct-xyz") - if !containsString(env, "CLOUDFLARE_ACCOUNT_ID=acct-xyz") { - t.Fatalf("env missing account id: %v", env) - } -} - -func TestWranglerEnvOverridesPreExistingValues(t *testing.T) { - parent := []string{ - "CLOUDFLARE_API_TOKEN=stale-token", - "CLOUDFLARE_ACCOUNT_ID=stale-acct", - } - env := wranglerEnv(parent, nil, "fresh-tok", "fresh-acct") - if containsString(env, "CLOUDFLARE_API_TOKEN=stale-token") { - t.Fatalf("stale token leaked: %v", env) - } - if containsString(env, "CLOUDFLARE_ACCOUNT_ID=stale-acct") { - t.Fatalf("stale account id leaked: %v", env) - } - if !containsString(env, "CLOUDFLARE_API_TOKEN=fresh-tok") { - t.Fatalf("fresh token missing: %v", env) - } -} - -// Injected env tests: project env vars merge into wrangler's env, but auth -// env vars always win (including against an injected map that contains a -// matching name). - -func TestWranglerEnvAuthWinsOverInjected(t *testing.T) { - injected := map[string]string{ - "CLOUDFLARE_API_TOKEN": "user-tried-to-override", - "CLOUDFLARE_ACCOUNT_ID": "user-tried-acct", - "API_URL": "https://api.example.com", - } - env := wranglerEnv(nil, injected, "profile-tok", "profile-acct") - if !containsString(env, "CLOUDFLARE_API_TOKEN=profile-tok") { - t.Fatalf("profile token not winning: %v", env) - } - if containsString(env, "CLOUDFLARE_API_TOKEN=user-tried-to-override") { - t.Fatalf("injected api token leaked: %v", env) - } - if !containsString(env, "CLOUDFLARE_ACCOUNT_ID=profile-acct") { - t.Fatalf("profile account id not winning: %v", env) - } - // Non-auth user vars do go through. - if !containsString(env, "API_URL=https://api.example.com") { - t.Fatalf("non-auth injected var missing: %v", env) - } -} - -func TestWranglerEnvInjectedOverridesShell(t *testing.T) { - parent := []string{"API_URL=stale", "DATABASE_HOST=ignored-shell"} - injected := map[string]string{"API_URL": "fresh", "FEATURE_FLAGS": "a,b"} - env := wranglerEnv(parent, injected, "tok", "") - if !containsString(env, "API_URL=fresh") { - t.Fatalf("injected API_URL should override shell: %v", env) - } - if containsString(env, "API_URL=stale") { - t.Fatalf("stale shell value leaked: %v", env) - } - if !containsString(env, "FEATURE_FLAGS=a,b") { - t.Fatalf("new injected key missing: %v", env) - } - // Shell-only vars are still passed through. - if !containsString(env, "DATABASE_HOST=ignored-shell") { - t.Fatalf("shell-only var dropped: %v", env) - } -} - -func TestWranglerEnvNilInjectedNoop(t *testing.T) { - parent := []string{"FOO=bar"} - env := wranglerEnv(parent, nil, "tok", "") - if !containsString(env, "FOO=bar") { - t.Fatalf("parent vars dropped: %v", env) - } - if !containsString(env, "CLOUDFLARE_API_TOKEN=tok") { - t.Fatalf("auth env missing: %v", env) - } -} - -// Apply: dry-run path ---------------------------------------------------- - -func TestApplyDryRunReturnsCleanArgvAndCommandLines(t *testing.T) { - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "tok-dry", - AccountID: "acct-dry", - Env: "prod", - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("Apply returned nil result") - } - if !res.DryRun { - t.Errorf("DryRun = false, want true") - } - if res.Schema != SchemaApply { - t.Errorf("Schema = %q, want %q", res.Schema, SchemaApply) - } - for _, a := range res.Argv { - if strings.Contains(a, "tok-dry") { - t.Fatalf("dry-run argv leaked token: %v", res.Argv) - } - } - if len(res.CommandLines) != 1 { - t.Fatalf("CommandLines len = %d, want 1 (deploy)", len(res.CommandLines)) - } - if !strings.Contains(res.CommandLines[0], "wrangler deploy") { - t.Errorf("command line should be `wrangler deploy ...`, got %q", res.CommandLines[0]) - } - if strings.Contains(res.CommandLines[0], "tok-dry") { - t.Fatalf("CommandLines leaked token: %q", res.CommandLines[0]) - } -} - -// Apply: validation paths ------------------------------------------------- - -func TestApplyEmptyTokenSurfacesCloudflareProfileInvalid(t *testing.T) { - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "", - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for empty APIToken") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "CLOUDFLARE_PROFILE_INVALID" { - t.Fatalf("error code = %v, want CLOUDFLARE_PROFILE_INVALID", err) - } -} - -// Apply: real exec via fake wrangler binary ------------------------------- - -func TestApplyRealExecCapturesDeploymentURL(t *testing.T) { - tmp := t.TempDir() - logPath := filepath.Join(tmp, "wrangler.log") - installFakeWrangler(t, tmp, logPath, "https://demo.example.workers.dev") - - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: tmp, - APIToken: "tok-real", - AccountID: "acct-real", - Env: "prod", - DryRun: false, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("nil result") - } - if res.DryRun { - t.Errorf("DryRun = true, want false") - } - if res.DeploymentURL != "https://demo.example.workers.dev" { - t.Errorf("DeploymentURL = %q, want https://demo.example.workers.dev", res.DeploymentURL) - } - raw, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read log: %v", err) - } - got := string(raw) - if !strings.Contains(got, "deploy") { - t.Errorf("log missing `deploy` invocation\n--- log:\n%s", got) - } - // fake wrangler dumps env + argv. Confirm token + account were - // threaded via env (not argv). - if !strings.Contains(got, "CLOUDFLARE_API_TOKEN=tok-real") { - t.Errorf("log missing token in env\n--- log:\n%s", got) - } - if !strings.Contains(got, "CLOUDFLARE_ACCOUNT_ID=acct-real") { - t.Errorf("log missing account id in env\n--- log:\n%s", got) - } - for _, a := range res.Argv { - if strings.Contains(a, "tok-real") { - t.Fatalf("real-run argv leaked token: %v", res.Argv) - } - } -} - -func TestApplyRealExecFindsProjectLocalWrangler(t *testing.T) { - tmp := t.TempDir() - projectDir := filepath.Join(tmp, "project") - logPath := filepath.Join(tmp, "wrangler.log") - installFakeWranglerAt(t, - filepath.Join(projectDir, "node_modules", ".bin"), - logPath, - "https://local.example.workers.dev") - emptyPath := filepath.Join(tmp, "empty-path") - if err := os.MkdirAll(emptyPath, 0o755); err != nil { - t.Fatalf("mkdir empty path: %v", err) - } - t.Setenv("PATH", emptyPath) - - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: projectDir, - APIToken: "tok-local", - AccountID: "acct-local", - Env: "prod", - DryRun: false, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res.DeploymentURL != "https://local.example.workers.dev" { - t.Errorf("DeploymentURL = %q, want project-local URL", res.DeploymentURL) - } - raw, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read log: %v", err) - } - got := string(raw) - if !strings.Contains(got, "CLOUDFLARE_API_TOKEN=tok-local") { - t.Fatalf("project-local wrangler did not receive token env\n--- log:\n%s", got) - } - if !strings.Contains(res.Argv[0], filepath.Join("node_modules", ".bin", "wrangler")) { - t.Fatalf("expected actual argv to use project-local wrangler, got %v", res.Argv) - } -} - -func TestApplyMissingCLISurfacesCloudflareCLIMissing(t *testing.T) { - prevBinary := CLIBinary - t.Cleanup(func() { CLIBinary = prevBinary }) - CLIBinary = "wrangler-this-binary-does-not-exist-xyz" - t.Setenv("PATH", t.TempDir()) - - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "tok", - DryRun: false, - }) - if err == nil { - t.Fatal("expected error when wrangler CLI missing") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "CLOUDFLARE_CLI_MISSING" { - t.Fatalf("error = %v, want CLOUDFLARE_CLI_MISSING", err) - } -} - -func TestRunDeployStepAddsD1RemediationWhenBindingConfigured(t *testing.T) { - tmp := t.TempDir() - if err := os.WriteFile(filepath.Join(tmp, WranglerConfigFilename), []byte(` -name = "demo" - -[[d1_databases]] -binding = "DB" -database_name = "demo-db" -database_id = "missing-id" -`), 0o644); err != nil { - t.Fatalf("write wrangler.toml: %v", err) - } - failingWrangler := filepath.Join(tmp, "wrangler") - failingBody := "#!/bin/sh\nexit 1\n" - if runtime.GOOS == "windows" { - failingWrangler += ".cmd" - failingBody = "@echo off\r\nexit /b 1\r\n" - } - if err := os.WriteFile(failingWrangler, []byte(failingBody), 0o755); err != nil { - t.Fatalf("write failing wrangler: %v", err) - } - - _, err := runDeployStep(context.Background(), tmp, []string{failingWrangler, "deploy"}, "tok", "", nil) - if err == nil { - t.Fatal("expected deploy failure") - } - outErr, ok := err.(*output.Error) - if !ok { - t.Fatalf("error type = %T, want *output.Error", err) - } - if outErr.Context["d1_binding_configured"] != true { - t.Fatalf("d1 context missing: %v", outErr.Context) - } - if !hasRemediationAction(outErr.Remediation, "verify-d1-binding") { - t.Fatalf("D1 remediation missing: %+v", outErr.Remediation) - } -} - -// helpers ---------------------------------------------------------------- - -func assertArgv(t *testing.T, got, want []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("argv len = %d, want %d\ngot: %v\nwant: %v", len(got), len(want), got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("argv[%d] = %q, want %q\nfull got: %v\nfull want: %v", i, got[i], want[i], got, want) - } - } -} - -func containsString(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false -} - -func containsPrefix(haystack []string, prefix string) bool { - for _, s := range haystack { - if strings.HasPrefix(s, prefix) { - return true - } - } - return false -} - -func hasRemediationAction(steps []output.Remediation, action string) bool { - for _, step := range steps { - if step.Action == action { - return true - } - } - return false -} - -// installFakeWrangler writes a shell script under /bin/wrangler -// that logs every invocation (env + argv) to logPath and prints a fake -// deploy URL on the `deploy` subcommand. Prepends /bin to $PATH -// for the duration of the test. -func installFakeWrangler(t *testing.T, dir, logPath, urlOnDeploy string) { - t.Helper() - bin := filepath.Join(dir, "bin") - installFakeWranglerAt(t, bin, logPath, urlOnDeploy) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) -} - -func installFakeWranglerAt(t *testing.T, bin, logPath, urlOnDeploy string) { - t.Helper() - if err := os.MkdirAll(bin, 0o755); err != nil { - t.Fatalf("mkdir fake bin: %v", err) - } - if runtime.GOOS == "windows" { - path := filepath.Join(bin, "wrangler.cmd") - body := "@echo off\r\n" + - ">>\"%WRANGLER_LOG%\" echo argv: %*\r\n" + - ">>\"%WRANGLER_LOG%\" echo CLOUDFLARE_API_TOKEN=%CLOUDFLARE_API_TOKEN%\r\n" + - ">>\"%WRANGLER_LOG%\" echo CLOUDFLARE_ACCOUNT_ID=%CLOUDFLARE_ACCOUNT_ID%\r\n" + - "if \"%~1\"==\"deploy\" (\r\n" + - " echo Total Upload: 1.23 KiB\r\n" + - " echo Published demo ^(1.2 sec^)\r\n" + - " echo " + urlOnDeploy + "\r\n" + - " echo Current Deployment ID: abc-123\r\n" + - ")\r\n" - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake wrangler: %v", err) - } - t.Setenv("WRANGLER_LOG", logPath) - return - } - path := filepath.Join(bin, "wrangler") - body := `#!/bin/sh -{ - echo "argv: $@" - echo "CLOUDFLARE_API_TOKEN=$CLOUDFLARE_API_TOKEN" - echo "CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID" -} >> "$WRANGLER_LOG" -case "$1" in - deploy) - printf 'Total Upload: 1.23 KiB\n' - printf 'Published demo (1.2 sec)\n' - printf ' ` + urlOnDeploy + `\n' - printf 'Current Deployment ID: abc-123\n' - ;; - *) - : - ;; -esac -` - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake wrangler: %v", err) - } - t.Setenv("WRANGLER_LOG", logPath) -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/provider.go b/packages/cli/internal/adapters/deploy/cloudflare/provider.go deleted file mode 100644 index 1b80765e..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/provider.go +++ /dev/null @@ -1,97 +0,0 @@ -package cloudflare - -// provider.go is the deploy/cloudflare adapter onto the deploy.Provider -// interface. Apply pulls the API token + optional account scope from -// the resolved profile, reads the per-project env name from the manifest, -// and shells out via ops.go's Apply. - -import ( - "context" - "path/filepath" - "sort" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -type providerImpl struct{} - -func Provider() deploy.Provider { return providerImpl{} } - -func (providerImpl) ID() string { return workspace.DeployBackendCloudflare } - -func (providerImpl) Apply(ctx context.Context, in deploy.ApplyInput) (*deploy.ApplyResult, error) { - if in.Resolved == nil || in.Resolved.Profile.Cloudflare == nil { - return nil, cliErrors.New(cliErrors.CLOUDFLARE_PROFILE_INVALID, - "deploy/cloudflare 需要先配置一个 deploy/cloudflare profile。先 `one configure add deploy/cloudflare --profile --use`。") - } - cp := in.Resolved.Profile.Cloudflare - if cp.Credentials == nil || cp.Credentials.APIToken == "" { - return nil, cliErrors.New(cliErrors.CLOUDFLARE_PROFILE_INVALID, - "deploy/cloudflare profile 缺 API token。在 dash.cloudflare.com → My Profile → API Tokens 创建后重新跑 `one configure add deploy/cloudflare --profile `。") - } - - projectDir := projectDirFor(in) - envName := envForProject(in.Manifest, in.Project.Name) - - res, err := Apply(ctx, ApplyInput{ - ProjectDir: projectDir, - APIToken: cp.Credentials.APIToken, - AccountID: cp.AccountID, - Env: envName, - DryRun: in.DryRun, - InjectedEnv: in.InjectedEnv, - }) - if err != nil { - return nil, err - } - if res == nil { - return nil, nil - } - return &deploy.ApplyResult{ - Schema: res.Schema, - Argv: res.Argv, - CommandLines: res.CommandLines, - DryRun: res.DryRun, - InjectedEnvKeys: sortedKeys(in.InjectedEnv), - InjectedEnvSource: in.InjectedEnvSource, - }, nil -} - -// sortedKeys returns the map's keys in alphabetical order. Returns nil -// for nil / empty input so the resulting ApplyResult field is omitted -// from the JSON envelope when there is no injection. -func sortedKeys(m map[string]string) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// projectDirFor returns the absolute filesystem dir for the project. -// Project.TargetDir is set by deploycmd; fall through to ProjectRoot + -// RelativeDir for callers that don't pre-resolve it. -func projectDirFor(in deploy.ApplyInput) string { - if in.Project.TargetDir != "" { - return in.Project.TargetDir - } - return filepath.Join(in.ProjectRoot, filepath.FromSlash(in.Project.RelativeDir)) -} - -// envForProject reads projects[i].domains.deploy.config.env. Empty when -// the manifest does not pin a value; resolveEnvName treats empty as -// production (no --env flag), preserving the prior "default to production" -// behaviour. -func envForProject(m *workspace.Manifest, projectName string) string { - cfg, _ := DecodeProjectConfig(m, projectName) - if cfg == nil { - return "" - } - return cfg.Env -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/provider_test.go b/packages/cli/internal/adapters/deploy/cloudflare/provider_test.go deleted file mode 100644 index 5b62b6e5..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/provider_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package cloudflare - -import ( - "context" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -func TestProviderFactory(t *testing.T) { - p := Provider() - if p.ID() != "cloudflare" { - t.Fatalf("provider ID = %q, want cloudflare", p.ID()) - } -} - -// envForProject: returns the manifest pin verbatim, or empty when the -// manifest does not declare one. Empty defaults to production at the -// ops layer via resolveEnvName. -func TestEnvForProject(t *testing.T) { - tests := []struct { - name string - m *workspace.Manifest - want string - }{ - { - name: "nil manifest yields empty (production default)", - m: nil, - want: "", - }, - { - name: "missing project yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "other"}}}, - want: "", - }, - { - name: "project without deploy section yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web"}, - }}, - want: "", - }, - { - name: "project without cloudflare config yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "cloudflare"}, - }}, - }}, - want: "", - }, - { - name: "explicit env=prod returns prod", - m: manifestWithCloudflareEnv(t, "prod"), - want: "prod", - }, - { - name: "explicit env=staging returns staging", - m: manifestWithCloudflareEnv(t, "staging"), - want: "staging", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := envForProject(tt.m, "web") - if got != tt.want { - t.Fatalf("envForProject = %q, want %q", got, tt.want) - } - }) - } -} - -// resolveEnvName maps the user-facing Env onto wrangler's --env flag. -// Empty / "prod" → "" (no flag); anything else → the value verbatim. -func TestResolveEnvName(t *testing.T) { - cases := []struct { - env string - want string - }{ - {"", ""}, - {"prod", ""}, - {" prod ", ""}, // trimmed - {"staging", "staging"}, - {"dev", "dev"}, - {"qa", "qa"}, - } - for _, c := range cases { - t.Run(c.env, func(t *testing.T) { - got := resolveEnvName(ApplyInput{Env: c.env}) - if got != c.want { - t.Fatalf("resolveEnvName(%q) = %q, want %q", c.env, got, c.want) - } - }) - } -} - -// projectDirFor honours an explicit TargetDir, otherwise joins -// ProjectRoot + RelativeDir. -func TestProjectDirFor(t *testing.T) { - withTarget := deploy.ApplyInput{ - ProjectRoot: "/repo", - Project: workspace.Project{ - Name: "web", - RelativeDir: "apps/web", - TargetDir: "/some/explicit/path", - }, - } - if got := projectDirFor(withTarget); got != "/some/explicit/path" { - t.Errorf("explicit TargetDir not honoured: got %q", got) - } - withoutTarget := deploy.ApplyInput{ - ProjectRoot: "/repo", - Project: workspace.Project{ - Name: "web", - RelativeDir: "apps/web", - }, - } - if want, got := filepath.Join("/repo", "apps", "web"), projectDirFor(withoutTarget); got != want { - t.Errorf("fallback path = %q, want %q", got, want) - } -} - -// Provider.Apply must reject an input with no resolved profile — -// without the API token there's nothing to authenticate as. -func TestProviderApplyMissingProfileSurfacesCloudflareProfileInvalid(t *testing.T) { - p := providerImpl{} - _, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{}, - Resolved: nil, - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for nil Resolved") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "CLOUDFLARE_PROFILE_INVALID" { - t.Fatalf("error = %v, want CLOUDFLARE_PROFILE_INVALID", err) - } -} - -// Provider.Apply also rejects a resolved profile whose Credentials -// pointer is nil or whose APIToken is empty. -func TestProviderApplyEmptyTokenSurfacesCloudflareProfileInvalid(t *testing.T) { - p := providerImpl{} - _, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{}, - Resolved: &profile.Resolved{ - Name: "default", - Profile: profile.Profile{Backend: "cloudflare", Cloudflare: &profile.CloudflareProfile{}}, - }, - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for empty token") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "CLOUDFLARE_PROFILE_INVALID" { - t.Fatalf("error = %v, want CLOUDFLARE_PROFILE_INVALID", err) - } -} - -// Provider.Apply happy path (dry-run): a resolved profile with token + -// account threads through to the underlying Apply, returns the -// deploy.ApplyResult envelope. argv must NOT contain the API token — -// auth flows via cmd.Env at exec time. -func TestProviderApplyDryRunReturnsArgvWithoutToken(t *testing.T) { - p := providerImpl{} - res, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "cloudflare"}, - }}, - }}, - Resolved: &profile.Resolved{ - Name: "default", - Profile: profile.Profile{ - Backend: "cloudflare", - Cloudflare: &profile.CloudflareProfile{ - AccountID: "acct-abc", - Credentials: &profile.CloudflareCredentials{APIToken: "secret-tok-1"}, - }, - }, - }, - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("nil result") - } - if !res.DryRun { - t.Errorf("DryRun = false, want true") - } - if res.Schema != SchemaApply { - t.Errorf("Schema = %q, want %q", res.Schema, SchemaApply) - } - for _, a := range res.Argv { - if contains(a, "secret-tok-1") { - t.Fatalf("argv leaked token: %v", res.Argv) - } - if contains(a, "acct-abc") { - t.Fatalf("argv leaked account id: %v", res.Argv) - } - } -} - -func contains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} - -func manifestWithCloudflareEnv(t *testing.T, env string) *workspace.Manifest { - t.Helper() - p := workspace.ManifestProject{Name: "web"} - if err := EncodeProjectConfig(&p, &ProjectConfig{Env: env}); err != nil { - t.Fatalf("EncodeProjectConfig: %v", err) - } - return &workspace.Manifest{Projects: []workspace.ManifestProject{p}} -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/sync.go b/packages/cli/internal/adapters/deploy/cloudflare/sync.go deleted file mode 100644 index 75843dff..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/sync.go +++ /dev/null @@ -1,151 +0,0 @@ -package cloudflare - -// sync.go scaffolds the project-side `wrangler.toml` file the first -// time a project picks deploy/cloudflare. Wrangler can auto-detect a -// lot, but a minimal toml with `name` + `compatibility_date` (and an -// `[assets]` block for static-asset-only sites) keeps the first-run -// flow non-interactive. - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "time" -) - -// WranglerConfigFilename is the canonical config file wrangler reads. -const WranglerConfigFilename = "wrangler.toml" - -const wranglerDevDependencyVersion = "^4.90.0" - -// ShouldSync reports whether a Sync call would do work — only when no -// wrangler.toml is present (we never overwrite a hand-edited file). -func ShouldSync(projectDir string) bool { - _, err := os.Stat(filepath.Join(projectDir, WranglerConfigFilename)) - return errors.Is(err, fs.ErrNotExist) -} - -// Sync writes a minimal wrangler.toml with shape guessed from the -// template id. Idempotent: existing files are left alone. -// -// The two shapes: -// -// - SSG / CSR / docs templates → static-assets Worker pointed at -// the build output dir (dist/ or build/). No `main` entry point. -// -// - SSR / API / unknown → empty-ish toml with only `name` + -// `compatibility_date`, leaving `main` for the user to fill in -// once they've decided on their adapter (e.g. @opennextjs/cloudflare -// for Next.js). -func Sync(projectDir, templateID, workerName string) error { - target := filepath.Join(projectDir, WranglerConfigFilename) - if !ShouldSync(projectDir) { - return nil - } - body := defaultConfig(templateID, workerName) - if err := os.WriteFile(target, []byte(body), 0o644); err != nil { - return err - } - return ensureWranglerDevDependency(projectDir) -} - -// defaultConfig returns the wrangler.toml body for a given template id. -// workerName falls back to the project's directory name when empty. -func defaultConfig(templateID, workerName string) string { - name := strings.TrimSpace(workerName) - if name == "" { - name = "one-app" - } - // 2024-09-23 is a stable wrangler default for Workers compatibility. - // We pin to the date this CLI's docs were written so first-run - // Workers behave deterministically; users can bump later. - compatDate := "2024-09-23" - if today := time.Now().UTC().Format("2006-01-02"); today < compatDate { - // guard against test clocks set far in the past — never use a - // future-dated compat date (wrangler rejects it). - compatDate = today - } - - switch templateID { - case "react-spa": - return fmt.Sprintf(`name = "%s" -compatibility_date = "%s" - -[assets] -directory = "./dist" -`, name, compatDate) - case "astro-site", "starlight-docs": - return fmt.Sprintf(`name = "%s" -compatibility_date = "%s" - -[assets] -directory = "./dist" -`, name, compatDate) - case "nextjs-app": - // Next.js on Workers needs an adapter (e.g. @opennextjs/cloudflare). - // We emit only the skeleton — the user wires `main` once they - // pick an adapter. Comment in-file points at the docs. - return fmt.Sprintf(`name = "%s" -compatibility_date = "%s" - -# Next.js on Cloudflare Workers needs an adapter. See: -# https://developers.cloudflare.com/workers/frameworks/framework-guides/nextjs/ -# Once installed, set: -# main = ".open-next/worker.js" -# [assets] -# directory = ".open-next/assets" -`, name, compatDate) - } - return fmt.Sprintf(`name = "%s" -compatibility_date = "%s" -`, name, compatDate) -} - -func ensureWranglerDevDependency(projectDir string) error { - pkgPath := filepath.Join(projectDir, "package.json") - raw, err := os.ReadFile(pkgPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return err - } - var pkg map[string]any - if err := json.Unmarshal(raw, &pkg); err != nil { - return err - } - if dependencySectionHas(pkg, "dependencies", "wrangler") || - dependencySectionHas(pkg, "devDependencies", "wrangler") { - return nil - } - devDeps := dependencySection(pkg, "devDependencies") - devDeps["wrangler"] = wranglerDevDependencyVersion - pkg["devDependencies"] = devDeps - out, err := json.MarshalIndent(pkg, "", " ") - if err != nil { - return err - } - out = append(out, '\n') - return os.WriteFile(pkgPath, out, 0o644) -} - -func dependencySectionHas(pkg map[string]any, section, name string) bool { - deps, ok := pkg[section].(map[string]any) - if !ok { - return false - } - _, ok = deps[name] - return ok -} - -func dependencySection(pkg map[string]any, section string) map[string]any { - deps, ok := pkg[section].(map[string]any) - if !ok { - return map[string]any{} - } - return deps -} diff --git a/packages/cli/internal/adapters/deploy/cloudflare/sync_test.go b/packages/cli/internal/adapters/deploy/cloudflare/sync_test.go deleted file mode 100644 index 861a1742..00000000 --- a/packages/cli/internal/adapters/deploy/cloudflare/sync_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package cloudflare - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestShouldSyncTrueWhenMissing(t *testing.T) { - if !ShouldSync(t.TempDir()) { - t.Errorf("ShouldSync on empty dir = false, want true") - } -} - -func TestShouldSyncFalseWhenPresent(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, WranglerConfigFilename), []byte("name = \"x\"\n"), 0o644); err != nil { - t.Fatalf("seed wrangler.toml: %v", err) - } - if ShouldSync(dir) { - t.Errorf("ShouldSync with existing wrangler.toml = true, want false") - } -} - -// Static-asset templates (CSR / SSG / docs) all get an `[assets]` block -// pointing at the build output dir. -func TestSyncWritesAssetsForStaticTemplates(t *testing.T) { - for _, tpl := range []string{"react-spa", "astro-site", "starlight-docs"} { - t.Run(tpl, func(t *testing.T) { - dir := t.TempDir() - seedPackageJSON(t, dir, `{"name":"demo","devDependencies":{"astro":"^6.0.0"}}`) - if err := Sync(dir, tpl, "demo"); err != nil { - t.Fatalf("Sync: %v", err) - } - body := readWranglerToml(t, dir) - if !strings.Contains(body, `name = "demo"`) { - t.Errorf("wrangler.toml missing name line: %s", body) - } - if !strings.Contains(body, "compatibility_date") { - t.Errorf("wrangler.toml missing compatibility_date: %s", body) - } - if !strings.Contains(body, "[assets]") { - t.Errorf("static template should emit [assets] block: %s", body) - } - if !strings.Contains(body, `directory = "./dist"`) { - t.Errorf("static template should point assets directory at ./dist: %s", body) - } - pkg := readPackageJSON(t, dir) - devDeps := pkg["devDependencies"].(map[string]any) - if devDeps["wrangler"] != wranglerDevDependencyVersion { - t.Fatalf("wrangler devDependency missing: %v", devDeps) - } - }) - } -} - -func TestSyncDoesNotOverwriteExistingWranglerDependency(t *testing.T) { - dir := t.TempDir() - seedPackageJSON(t, dir, `{"name":"demo","devDependencies":{"wrangler":"^4.1.0"}}`) - if err := Sync(dir, "astro-site", "demo"); err != nil { - t.Fatalf("Sync: %v", err) - } - pkg := readPackageJSON(t, dir) - devDeps := pkg["devDependencies"].(map[string]any) - if devDeps["wrangler"] != "^4.1.0" { - t.Fatalf("existing wrangler version was overwritten: %v", devDeps) - } -} - -// SSR (Next.js) gets a skeleton with a docs comment but no `main` line -// — the user wires it after picking an adapter. -func TestSyncWritesSkeletonForNextjs(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "nextjs-app", "demo"); err != nil { - t.Fatalf("Sync: %v", err) - } - body := readWranglerToml(t, dir) - if !strings.Contains(body, `name = "demo"`) { - t.Errorf("wrangler.toml missing name line: %s", body) - } - // The skeleton should NOT have an active `main = ...` line — but - // it DOES point users at the docs in a comment (which our regex - // would catch). Look for an uncommented `main =` at the start of - // any line. - for _, line := range strings.Split(body, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "main =") || strings.HasPrefix(trimmed, "main=") { - t.Errorf("Next.js skeleton should NOT preset main (let user pick adapter): %s", body) - break - } - } - if !strings.Contains(body, "developers.cloudflare.com") { - t.Errorf("Next.js skeleton should point user at framework guide: %s", body) - } -} - -// Unknown templates still produce a valid minimal toml — wrangler can -// auto-detect a lot once the user adds entry-point fields. -func TestSyncWritesMinimalConfigForUnknownTemplate(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "some-unknown-template", "demo"); err != nil { - t.Fatalf("Sync: %v", err) - } - body := readWranglerToml(t, dir) - if !strings.Contains(body, `name = "demo"`) { - t.Errorf("unknown template missing name: %s", body) - } - if !strings.Contains(body, "compatibility_date") { - t.Errorf("unknown template missing compatibility_date: %s", body) - } -} - -// Empty workerName falls back to a placeholder so wrangler.toml is -// still parseable on first run. -func TestSyncFallsBackToDefaultWorkerName(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "react-spa", ""); err != nil { - t.Fatalf("Sync: %v", err) - } - body := readWranglerToml(t, dir) - if !strings.Contains(body, `name = "one-app"`) { - t.Errorf("default worker name missing: %s", body) - } -} - -// Sync is idempotent — existing wrangler.toml is never overwritten. -func TestSyncIsIdempotent(t *testing.T) { - dir := t.TempDir() - pre := []byte(`name = "custom"` + "\n") - if err := os.WriteFile(filepath.Join(dir, WranglerConfigFilename), pre, 0o644); err != nil { - t.Fatalf("seed: %v", err) - } - if err := Sync(dir, "react-spa", "demo"); err != nil { - t.Fatalf("Sync: %v", err) - } - raw, err := os.ReadFile(filepath.Join(dir, WranglerConfigFilename)) - if err != nil { - t.Fatalf("read: %v", err) - } - if string(raw) != string(pre) { - t.Fatalf("Sync overwrote existing wrangler.toml:\nbefore: %s\nafter: %s", pre, raw) - } -} - -func readWranglerToml(t *testing.T, dir string) string { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, WranglerConfigFilename)) - if err != nil { - t.Fatalf("read wrangler.toml: %v", err) - } - return string(raw) -} - -func seedPackageJSON(t *testing.T, dir, body string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(body), 0o644); err != nil { - t.Fatalf("write package.json: %v", err) - } -} - -func readPackageJSON(t *testing.T, dir string) map[string]any { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, "package.json")) - if err != nil { - t.Fatalf("read package.json: %v", err) - } - var out map[string]any - if err := json.Unmarshal(raw, &out); err != nil { - t.Fatalf("parse package.json: %v", err) - } - return out -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/config.go b/packages/cli/internal/adapters/deploy/edgeone/config.go deleted file mode 100644 index 66fb6bff..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/config.go +++ /dev/null @@ -1,69 +0,0 @@ -package edgeone - -import ( - "encoding/json" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// ProjectConfig is the typed view over -// `projects[i].domains.deploy.config` when the per-project deploy backend -// is EdgeOne. ProjectName is the EdgeOne Pages project slug — required -// when running `edgeone pages deploy`. Env names the deploy target -// environment (drawn from manifest.environments.names; empty / "prod" → -// production deploy, anything else → preview). -type ProjectConfig struct { - ProjectName string `json:"projectName,omitempty"` - Env string `json:"env,omitempty"` -} - -// DecodeProjectConfig pulls the EdgeOne-specific config blob out of the -// manifest's per-project deploy section. Returns (nil, nil) when no -// project with that name has an EdgeOne deploy section configured. -func DecodeProjectConfig(m *workspace.Manifest, projectName string) (*ProjectConfig, error) { - if m == nil { - return nil, nil - } - for _, p := range m.Projects { - if p.Name != projectName { - continue - } - if p.Domains == nil || p.Domains.Deploy == nil || p.Domains.Deploy.Kind != workspace.DeployBackendEdgeOne { - return nil, nil - } - if len(p.Domains.Deploy.Config) == 0 { - return &ProjectConfig{}, nil - } - var cfg ProjectConfig - if err := json.Unmarshal(p.Domains.Deploy.Config, &cfg); err != nil { - return nil, err - } - return &cfg, nil - } - return nil, nil -} - -// EncodeProjectConfig writes cfg back into projects[i].domains.deploy.config -// (in memory), creating the deploy section if necessary. Caller persists -// via WriteManifest. -func EncodeProjectConfig(p *workspace.ManifestProject, cfg *ProjectConfig) error { - if p == nil { - return nil - } - if p.Domains == nil { - p.Domains = &workspace.ProjectDomains{} - } - if p.Domains.Deploy == nil { - p.Domains.Deploy = &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendEdgeOne} - } - if cfg == nil { - p.Domains.Deploy.Config = nil - return nil - } - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - p.Domains.Deploy.Config = raw - return nil -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/ops.go b/packages/cli/internal/adapters/deploy/edgeone/ops.go deleted file mode 100644 index 9e84c610..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/ops.go +++ /dev/null @@ -1,244 +0,0 @@ -// Package edgeone implements the deploy/edgeone backend. The provider -// shells out to Tencent Cloud's `edgeone` CLI rather than reimplementing -// the EdgeOne Pages REST API in Go: the CLI handles project linking, -// asset upload, deployment polling, and version routing for us. -// -// Layout follows the same shape as adapters/deploy/cloudflare — ops.go houses -// argv builders + the actual exec call so it stays trivially testable; -// sync.go scaffolds an edgeone.json hint; provider.go adapts everything -// onto the deploy.Provider interface. Bootstrap registers it explicitly. -// -// Auth threading: the current edgeone CLI authenticates non-interactive -// deploys via `edgeone pages deploy --token`. Dry-run and result -// envelopes redact the token before printing. -// -// NOTE: the edgeone CLI is less stable than wrangler — flag set, -// sub-commands, and even the auth env var names have changed across -// 2024–2026. The argv builder below targets `edgeone pages deploy` -// against the asset directory; if your edgeone CLI version differs, -// adjust ops.go and re-run the unit tests. The schema version below -// gives us a clean break point if we need to ship a v2. -package edgeone - -import ( - "context" - "fmt" - "os" - "os/exec" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "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/ports/secrets" -) - -// SchemaApply is the JSON envelope schema string for an edgeone deploy. -const SchemaApply = "one-cli/deploy-apply-edgeone/v1" - -// CLIBinary is the executable name we look for on $PATH. Exported so -// tests can stub it. -var CLIBinary = "edgeone" - -const redactedToken = "" - -// ApplyInput addresses Apply. -type ApplyInput struct { - // ProjectDir is the absolute working directory edgeone runs in - // (the per-project dir, not the workspace root). Used both as - // `--directory` for the deploy upload and as the cwd for the - // CLI invocation. - ProjectDir string - - // AssetDir is the optional sub-directory containing the build - // output (e.g. "dist", ".output/public"). Empty defaults to - // ProjectDir; non-empty is joined under ProjectDir. - AssetDir string - - // APIToken is the EdgeOne Pages API token. The upstream CLI accepts - // it as --token; dry-run / JSON output always redact it. - APIToken string - - // Region is the optional Tencent region slug. Retained in the - // profile for future upstream CLI support. - Region string - - // ProjectName is the EdgeOne Pages project slug (--project-name). - // When empty, edgeone CLI falls back to edgeone.json or prompts. - ProjectName string - - // Env names the deploy target environment (from manifest.environments.names, - // or the deploy command's --env flag). Empty or "prod" runs a production - // deploy; any other value runs a preview deploy (--env=preview on the - // upstream CLI). EdgeOne Pages only exposes the two-state production/ - // preview distinction, so staging or custom envs collapse to preview. - Env string - - // DryRun returns the planned argv without invoking edgeone. - DryRun bool - - // InjectedEnv is the project's user-set env vars (resolved by - // deploycmd from dotenv / Infisical). Merged into edgeone's child - // env before the auth env vars; the static-asset build typically - // runs upstream of `edgeone pages deploy`, but any in-CLI bundling - // step still sees them via process.env. nil = none. - InjectedEnv map[string]string -} - -// ApplyResult is the JSON envelope emitted on success. -type ApplyResult struct { - Schema string `json:"schema"` - Argv []string `json:"argv"` - CommandLines []string `json:"command_lines,omitempty"` - DryRun bool `json:"dry_run"` - - // DeploymentURL is captured from the edgeone CLI output on - // success. Empty in dry-run. - DeploymentURL string `json:"deployment_url,omitempty"` -} - -// Apply runs the edgeone CLI to deploy one project: -// -// edgeone pages deploy [--name ] [--env preview] -// -// One step: edgeone bundles + uploads atomically. -func Apply(ctx context.Context, in ApplyInput) (*ApplyResult, error) { - deployArgv := buildDeployArgv(in) - displayArgv := redactedDeployArgv(in) - commandLines := []string{argvDisplay(displayArgv)} - - if in.DryRun { - return &ApplyResult{ - Schema: SchemaApply, - Argv: displayArgv, - CommandLines: commandLines, - DryRun: true, - }, nil - } - - if _, err := exec.LookPath(CLIBinary); err != nil { - return nil, cliErrors.New(cliErrors.EDGEONE_CLI_MISSING, - "未在 PATH 中找到 `edgeone` 二进制。安装方式见错误的 remediation。"). - WithContext(map[string]any{"binary": CLIBinary}) - } - - url, err := runDeployStep(ctx, in.ProjectDir, deployArgv, in) - if err != nil { - return nil, err - } - return &ApplyResult{ - Schema: SchemaApply, - Argv: displayArgv, - CommandLines: commandLines, - DryRun: false, - DeploymentURL: url, - }, nil -} - -// isProduction reports whether the requested env represents EdgeOne's -// production tier. Empty (default) and "prod" both map to production; -// any other value (e.g. "dev", "staging", custom) maps to preview. -func isProduction(env string) bool { - env = strings.TrimSpace(env) - return env == "" || env == "prod" -} - -func buildDeployArgv(in ApplyInput) []string { - argv := []string{CLIBinary, "pages", "deploy"} - dir := strings.TrimSpace(in.AssetDir) - if dir == "" { - dir = "." - } - argv = append(argv, dir) - if name := strings.TrimSpace(in.ProjectName); name != "" { - argv = append(argv, "--name="+name) - } - if token := strings.TrimSpace(in.APIToken); token != "" { - argv = append(argv, "--token", token) - } - if !isProduction(in.Env) { - argv = append(argv, "--env=preview") - } - return argv -} - -func redactedDeployArgv(in ApplyInput) []string { - if strings.TrimSpace(in.APIToken) != "" { - in.APIToken = redactedToken - } - return buildDeployArgv(in) -} - -// argvDisplay joins argv into a copy-pasteable single-line string for -// dry-run / command_lines output. -func argvDisplay(argv []string) string { - return strings.Join(argv, " ") -} - -// runDeployStep streams edgeone stdout/stderr to the user's terminal -// while sniffing for the deployment URL. EdgeOne Pages prints the URL -// on its own line ending in `.pages.tencent.com` or `.eo.dev`. -func runDeployStep(ctx context.Context, dir string, argv []string, in ApplyInput) (string, error) { - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Dir = dir - cmd.Stderr = os.Stderr - cmd.Env = edgeoneEnv(os.Environ(), in.InjectedEnv) - stdout, err := cmd.StdoutPipe() - if err != nil { - return "", err - } - if err := cmd.Start(); err != nil { - return "", err - } - url := captureDeploymentURL(stdout) - if err := cmd.Wait(); err != nil { - return "", cliErrors.New(cliErrors.EDGEONE_DEPLOY_FAILED, - fmt.Sprintf("`edgeone pages deploy` 失败:%v", err)). - WithContext(map[string]any{"argv": argv}). - WithRemediation(output.Remediation{ - Action: "rerun-step", - Hint: "在项目目录手动跑一次 `edgeone pages deploy`,看完整 edgeone CLI 输出", - }) - } - return url, nil -} - -// edgeoneEnv builds the env edgeone runs under. User-injected env vars -// override the parent shell so e.g. an .env API_URL beats a stale shell -// API_URL. -func edgeoneEnv(parent []string, injected map[string]string) []string { - return secrets.MergeIntoEnviron(parent, injected, true) -} - -// captureDeploymentURL streams the CLI stdout to the user's terminal -// while sniffing for the first https:// URL ending with -// `.pages.tencent.com` / `.eo.dev` / `.edgeone.app` (the three known -// EdgeOne Pages domain suffixes as of writing). -func captureDeploymentURL(stdout interface{ Read(p []byte) (int, error) }) string { - buf := make([]byte, 4096) - var collected strings.Builder - for { - n, err := stdout.Read(buf) - if n > 0 { - os.Stdout.Write(buf[:n]) - collected.Write(buf[:n]) - } - if err != nil { - break - } - } - for _, line := range strings.Split(collected.String(), "\n") { - idx := strings.Index(line, "https://") - if idx < 0 { - continue - } - candidate := strings.TrimSpace(line[idx:]) - candidate = strings.TrimRight(candidate, ".,;)") - if strings.Contains(candidate, ".pages.tencent.com") || - strings.Contains(candidate, ".eo.dev") || - strings.Contains(candidate, ".edgeone.app") { - return candidate - } - } - return "" -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/ops_test.go b/packages/cli/internal/adapters/deploy/edgeone/ops_test.go deleted file mode 100644 index eec37261..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/ops_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package edgeone - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -// argv builders ---------------------------------------------------------- - -func TestBuildDeployArgvProductionDefaultDir(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "token", - ProjectName: "demo", - Env: "prod", - }) - want := []string{CLIBinary, "pages", "deploy", ".", "--name=demo", "--token", "token"} - assertArgv(t, got, want) -} - -// Empty Env defaults to the production tier (mirrors the pre-v4 behaviour -// where the unset preview field meant "ship to prod"). -func TestBuildDeployArgvEmptyEnvDefaultsToProduction(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "token", - ProjectName: "demo", - }) - want := []string{CLIBinary, "pages", "deploy", ".", "--name=demo", "--token", "token"} - assertArgv(t, got, want) -} - -func TestBuildDeployArgvPreviewWithCustomAssetDir(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "token", - AssetDir: "dist", - ProjectName: "demo", - Env: "dev", - }) - want := []string{CLIBinary, "pages", "deploy", "dist", "--name=demo", "--token", "token", "--env=preview"} - assertArgv(t, got, want) -} - -// Any non-prod env collapses to preview (EdgeOne only has two tiers). -func TestBuildDeployArgvStagingCollapsesToPreview(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "token", - AssetDir: "dist", - ProjectName: "demo", - Env: "staging", - }) - want := []string{CLIBinary, "pages", "deploy", "dist", "--name=demo", "--token", "token", "--env=preview"} - assertArgv(t, got, want) -} - -func TestBuildDeployArgvWithoutProjectName(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "token", - AssetDir: "dist", - Env: "prod", - }) - want := []string{CLIBinary, "pages", "deploy", "dist", "--token", "token"} - assertArgv(t, got, want) -} - -func TestRedactedDeployArgvHidesToken(t *testing.T) { - got := redactedDeployArgv(ApplyInput{ - APIToken: "edgeone-secret-token", - ProjectName: "demo", - Env: "prod", - }) - for _, a := range got { - if strings.Contains(a, "edgeone-secret-token") { - t.Fatalf("redacted argv leaked token: %v", got) - } - } - if !containsString(got, "--token") || !containsString(got, redactedToken) { - t.Fatalf("redacted token missing: %v", got) - } -} - -func TestEdgeOneEnvInjectedVarsOverrideShell(t *testing.T) { - parent := []string{"API_URL=stale", "DATABASE_HOST=ignored-shell"} - injected := map[string]string{"API_URL": "fresh", "FEATURE_FLAGS": "a,b"} - env := edgeoneEnv(parent, injected) - if !containsString(env, "API_URL=fresh") { - t.Fatalf("injected API_URL should override shell: %v", env) - } - if containsString(env, "API_URL=stale") { - t.Fatalf("stale shell value leaked: %v", env) - } - if !containsString(env, "FEATURE_FLAGS=a,b") { - t.Fatalf("new injected key missing: %v", env) - } - if !containsString(env, "DATABASE_HOST=ignored-shell") { - t.Fatalf("shell-only var dropped: %v", env) - } -} - -func TestEdgeOneEnvNilInjectedNoop(t *testing.T) { - parent := []string{"FOO=bar"} - env := edgeoneEnv(parent, nil) - if !containsString(env, "FOO=bar") { - t.Fatalf("parent vars dropped: %v", env) - } -} - -// Apply: dry-run path ---------------------------------------------------- - -func TestApplyDryRunReturnsCleanArgvAndCommandLines(t *testing.T) { - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "token-dry", - ProjectName: "demo", - Env: "prod", - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("Apply returned nil result") - } - if !res.DryRun { - t.Errorf("DryRun = false, want true") - } - if res.Schema != SchemaApply { - t.Errorf("Schema = %q, want %q", res.Schema, SchemaApply) - } - for _, a := range res.Argv { - if strings.Contains(a, "token-dry") { - t.Fatalf("dry-run argv leaked token: %v", res.Argv) - } - } - if len(res.CommandLines) != 1 { - t.Fatalf("CommandLines len = %d, want 1", len(res.CommandLines)) - } - if !strings.Contains(res.CommandLines[0], "edgeone pages deploy") { - t.Errorf("command line should be `edgeone pages deploy ...`, got %q", res.CommandLines[0]) - } -} - -func TestApplyDryRunWithoutTokenUsesLoginState(t *testing.T) { - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "", - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - for _, a := range res.Argv { - if a == "--token" { - t.Fatalf("unexpected token flag without token: %v", res.Argv) - } - } -} - -// Apply: real exec via fake edgeone binary -------------------------------- - -func TestApplyRealExecCapturesDeploymentURL(t *testing.T) { - tmp := t.TempDir() - logPath := filepath.Join(tmp, "edgeone.log") - installFakeEdgeOne(t, tmp, logPath, "https://demo.eo.dev") - - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: tmp, - APIToken: "token-real", - Region: "ap-guangzhou", - ProjectName: "demo", - Env: "prod", - DryRun: false, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("nil result") - } - if res.DeploymentURL != "https://demo.eo.dev" { - t.Errorf("DeploymentURL = %q, want https://demo.eo.dev", res.DeploymentURL) - } - raw, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read log: %v", err) - } - got := string(raw) - if runtime.GOOS == "windows" { - got = strings.ReplaceAll(got, "\"", "") - } - if !strings.Contains(got, "--token token-real") { - t.Errorf("log missing token flag\n--- log:\n%s", got) - } - for _, a := range res.Argv { - if strings.Contains(a, "token-real") { - t.Fatalf("real-run argv leaked token: %v", res.Argv) - } - } -} - -func TestApplyMissingCLISurfacesEdgeOneCLIMissing(t *testing.T) { - prevBinary := CLIBinary - t.Cleanup(func() { CLIBinary = prevBinary }) - CLIBinary = "edgeone-this-binary-does-not-exist-xyz" - t.Setenv("PATH", t.TempDir()) - - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "token", - DryRun: false, - }) - if err == nil { - t.Fatal("expected error when edgeone CLI missing") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "EDGEONE_CLI_MISSING" { - t.Fatalf("error = %v, want EDGEONE_CLI_MISSING", err) - } -} - -// helpers ---------------------------------------------------------------- - -func assertArgv(t *testing.T, got, want []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("argv len = %d, want %d\ngot: %v\nwant: %v", len(got), len(want), got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("argv[%d] = %q, want %q\nfull got: %v\nfull want: %v", i, got[i], want[i], got, want) - } - } -} - -func containsString(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false -} - -func containsPrefix(haystack []string, prefix string) bool { - for _, s := range haystack { - if strings.HasPrefix(s, prefix) { - return true - } - } - return false -} - -// installFakeEdgeOne writes a shell script under /bin/edgeone that -// logs every invocation (env + argv) to logPath and prints a fake -// deploy URL on the `pages deploy` subcommand. -func installFakeEdgeOne(t *testing.T, dir, logPath, urlOnDeploy string) { - t.Helper() - bin := filepath.Join(dir, "bin") - if err := os.MkdirAll(bin, 0o755); err != nil { - t.Fatalf("mkdir fake bin: %v", err) - } - if runtime.GOOS == "windows" { - path := filepath.Join(bin, "edgeone.cmd") - body := "@echo off\r\n" + - ">>\"%EDGEONE_LOG%\" echo argv: %*\r\n" + - "if \"%~1\"==\"pages\" if \"%~2\"==\"deploy\" (\r\n" + - " echo Uploading assets to EdgeOne Pages...\r\n" + - " echo Deployment ready at: " + urlOnDeploy + "\r\n" + - ")\r\n" - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake edgeone: %v", err) - } - t.Setenv("EDGEONE_LOG", logPath) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - return - } - path := filepath.Join(bin, "edgeone") - body := `#!/bin/sh -{ - echo "argv: $@" -} >> "$EDGEONE_LOG" -case "$1" in - pages) - if [ "$2" = "deploy" ]; then - printf 'Uploading assets to EdgeOne Pages...\n' - printf 'Deployment ready at: ` + urlOnDeploy + `\n' - fi - ;; - *) - : - ;; -esac -` - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake edgeone: %v", err) - } - t.Setenv("EDGEONE_LOG", logPath) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/provider.go b/packages/cli/internal/adapters/deploy/edgeone/provider.go deleted file mode 100644 index fe8dcc43..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/provider.go +++ /dev/null @@ -1,108 +0,0 @@ -package edgeone - -// provider.go is the deploy/edgeone adapter onto the deploy.Provider -// interface. Apply pulls the API token from the resolved profile, -// reads the per-project ProjectName + env name from the manifest, and -// shells out via ops.go's Apply. - -import ( - "context" - "path/filepath" - "sort" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -type providerImpl struct{} - -func Provider() deploy.Provider { return providerImpl{} } - -func (providerImpl) ID() string { return workspace.DeployBackendEdgeOne } - -func (providerImpl) Apply(ctx context.Context, in deploy.ApplyInput) (*deploy.ApplyResult, error) { - if in.Resolved == nil || in.Resolved.Profile.EdgeOne == nil { - return nil, cliErrors.New(cliErrors.EDGEONE_PROFILE_INVALID, - "deploy/edgeone 需要先配置一个 deploy/edgeone profile。先 `one configure add deploy/edgeone --profile --use`。") - } - ep := in.Resolved.Profile.EdgeOne - apiToken := "" - if ep.Credentials != nil { - apiToken = ep.Credentials.APIToken - } - - projectDir := projectDirFor(in) - envName := envForProject(in.Manifest, in.Project.Name) - projectName := projectNameForProject(in.Manifest, in.Project.Name) - - res, err := Apply(ctx, ApplyInput{ - ProjectDir: projectDir, - AssetDir: defaultOutputDir(in.Project.TemplateID), - APIToken: apiToken, - Region: ep.Region, - ProjectName: projectName, - Env: envName, - DryRun: in.DryRun, - InjectedEnv: in.InjectedEnv, - }) - if err != nil { - return nil, err - } - if res == nil { - return nil, nil - } - return &deploy.ApplyResult{ - Schema: res.Schema, - Argv: res.Argv, - CommandLines: res.CommandLines, - DryRun: res.DryRun, - InjectedEnvKeys: sortedKeys(in.InjectedEnv), - InjectedEnvSource: in.InjectedEnvSource, - }, nil -} - -// sortedKeys returns the map's keys in alphabetical order. Returns nil -// for nil / empty input so the resulting ApplyResult field is omitted -// from the JSON envelope when there is no injection. -func sortedKeys(m map[string]string) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// projectDirFor returns the absolute filesystem dir for the project. -func projectDirFor(in deploy.ApplyInput) string { - if in.Project.TargetDir != "" { - return in.Project.TargetDir - } - return filepath.Join(in.ProjectRoot, filepath.FromSlash(in.Project.RelativeDir)) -} - -// envForProject reads projects[i].domains.deploy.config.env. Empty when -// the manifest does not pin a value; ops.isProduction treats empty as the -// production tier, preserving the prior "default to production" behaviour. -func envForProject(m *workspace.Manifest, projectName string) string { - cfg, _ := DecodeProjectConfig(m, projectName) - if cfg == nil { - return "" - } - return cfg.Env -} - -// projectNameForProject reads projects[i].domains.deploy.config.projectName. -// Empty when no manifest pin is set; the edgeone CLI then falls back to -// edgeone.json or interactive prompt. -func projectNameForProject(m *workspace.Manifest, projectName string) string { - cfg, _ := DecodeProjectConfig(m, projectName) - if cfg == nil { - return "" - } - return cfg.ProjectName -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/provider_test.go b/packages/cli/internal/adapters/deploy/edgeone/provider_test.go deleted file mode 100644 index 543e08bc..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/provider_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package edgeone - -import ( - "context" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -func TestProviderFactory(t *testing.T) { - p := Provider() - if p.ID() != "edgeone" { - t.Fatalf("provider ID = %q, want edgeone", p.ID()) - } -} - -// envForProject: returns the manifest pin verbatim, or empty when the -// manifest does not declare one. Empty defaults to production at the -// ops layer via isProduction. -func TestEnvForProject(t *testing.T) { - tests := []struct { - name string - m *workspace.Manifest - want string - }{ - { - name: "nil manifest yields empty", - m: nil, - want: "", - }, - { - name: "missing project yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "other"}}}, - want: "", - }, - { - name: "project without deploy section yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web"}, - }}, - want: "", - }, - { - name: "project without edgeone config yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "edgeone"}, - }}, - }}, - want: "", - }, - { - name: "explicit env=prod returns prod", - m: manifestWithEdgeOneConfig(t, &ProjectConfig{Env: "prod"}), - want: "prod", - }, - { - name: "explicit env=staging returns staging", - m: manifestWithEdgeOneConfig(t, &ProjectConfig{Env: "staging"}), - want: "staging", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := envForProject(tt.m, "web") - if got != tt.want { - t.Fatalf("envForProject = %q, want %q", got, tt.want) - } - }) - } -} - -// isProduction collapses the env name to EdgeOne's two-state tier: -// empty / "prod" → production; everything else → preview. -func TestIsProduction(t *testing.T) { - cases := []struct { - env string - want bool - }{ - {"", true}, - {"prod", true}, - {"dev", false}, - {"staging", false}, - {"qa", false}, - {" prod ", true}, - } - for _, c := range cases { - t.Run(c.env, func(t *testing.T) { - if got := isProduction(c.env); got != c.want { - t.Fatalf("isProduction(%q) = %v, want %v", c.env, got, c.want) - } - }) - } -} - -func TestProjectNameForProjectReadsManifestPin(t *testing.T) { - m := manifestWithEdgeOneConfig(t, &ProjectConfig{ProjectName: "demo-eo"}) - if got := projectNameForProject(m, "web"); got != "demo-eo" { - t.Errorf("projectName = %q, want demo-eo", got) - } - if got := projectNameForProject(m, "missing"); got != "" { - t.Errorf("missing project should give empty name, got %q", got) - } - if got := projectNameForProject(nil, "web"); got != "" { - t.Errorf("nil manifest should give empty name, got %q", got) - } -} - -func TestProjectDirFor(t *testing.T) { - withTarget := deploy.ApplyInput{ - ProjectRoot: "/repo", - Project: workspace.Project{ - Name: "web", - RelativeDir: "apps/web", - TargetDir: "/some/explicit/path", - }, - } - if got := projectDirFor(withTarget); got != "/some/explicit/path" { - t.Errorf("explicit TargetDir not honoured: got %q", got) - } - withoutTarget := deploy.ApplyInput{ - ProjectRoot: "/repo", - Project: workspace.Project{ - Name: "web", - RelativeDir: "apps/web", - }, - } - if want, got := filepath.Join("/repo", "apps", "web"), projectDirFor(withoutTarget); got != want { - t.Errorf("fallback path = %q, want %q", got, want) - } -} - -// Provider.Apply must reject inputs missing profile / credentials. -func TestProviderApplyMissingProfileSurfacesEdgeOneProfileInvalid(t *testing.T) { - p := providerImpl{} - _, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{}, - Resolved: nil, - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for nil Resolved") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "EDGEONE_PROFILE_INVALID" { - t.Fatalf("error = %v, want EDGEONE_PROFILE_INVALID", err) - } -} - -func TestProviderApplyWithoutTokenFallsBackToEdgeOneLogin(t *testing.T) { - p := providerImpl{} - res, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{}, - Resolved: &profile.Resolved{ - Name: "default", - Profile: profile.Profile{Backend: "edgeone", EdgeOne: &profile.EdgeOneProfile{}}, - }, - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - for _, a := range res.Argv { - if a == "--token" { - t.Fatalf("unexpected token flag without token: %v", res.Argv) - } - } -} - -// Provider.Apply happy path (dry-run): argv must NOT contain the real token. -func TestProviderApplyDryRunReturnsArgvWithoutSecrets(t *testing.T) { - p := providerImpl{} - res, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "edgeone"}, - }}, - }}, - Resolved: &profile.Resolved{ - Name: "default", - Profile: profile.Profile{ - Backend: "edgeone", - EdgeOne: &profile.EdgeOneProfile{ - Region: "ap-guangzhou", - Credentials: &profile.EdgeOneCredentials{ - APIToken: "edgeone-secret-token", - }, - }, - }, - }, - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("nil result") - } - if !res.DryRun { - t.Errorf("DryRun = false, want true") - } - if res.Schema != SchemaApply { - t.Errorf("Schema = %q, want %q", res.Schema, SchemaApply) - } - for _, a := range res.Argv { - if contains(a, "edgeone-secret-token") { - t.Fatalf("argv leaked token: %v", res.Argv) - } - } -} - -func contains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} - -func manifestWithEdgeOneConfig(t *testing.T, cfg *ProjectConfig) *workspace.Manifest { - t.Helper() - p := workspace.ManifestProject{Name: "web"} - if err := EncodeProjectConfig(&p, cfg); err != nil { - t.Fatalf("EncodeProjectConfig: %v", err) - } - return &workspace.Manifest{Projects: []workspace.ManifestProject{p}} -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/sync.go b/packages/cli/internal/adapters/deploy/edgeone/sync.go deleted file mode 100644 index 43aa9998..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/sync.go +++ /dev/null @@ -1,71 +0,0 @@ -package edgeone - -// sync.go scaffolds a project-side `edgeone.json` hint the first time -// a project picks deploy/edgeone. The CLI doesn't strictly require -// this file, but pre-seeding the project name + asset directory keeps -// the first-run flow non-interactive. -// -// NOTE: edgeone CLI's exact config-file shape is not yet stabilized; -// the schema below is the minimum that CI runs survive. If the CLI -// ever rejects unknown keys, narrow this further. - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" -) - -// EdgeOneConfigFilename is the canonical config file edgeone reads. -const EdgeOneConfigFilename = "edgeone.json" - -// ShouldSync reports whether a Sync call would do work — only when no -// edgeone.json is present (we never overwrite a hand-edited file). -func ShouldSync(projectDir string) bool { - _, err := os.Stat(filepath.Join(projectDir, EdgeOneConfigFilename)) - return errors.Is(err, fs.ErrNotExist) -} - -// Sync writes a minimal edgeone.json hint. Idempotent: existing files -// are left alone. projectName / outputDir are persisted as the -// project's identity in the EdgeOne console; outputDir defaults to -// "dist" when empty. -func Sync(projectDir, templateID, projectName string) error { - target := filepath.Join(projectDir, EdgeOneConfigFilename) - if !ShouldSync(projectDir) { - return nil - } - cfg := defaultConfig(templateID, projectName) - raw, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - raw = append(raw, '\n') - return os.WriteFile(target, raw, 0o644) -} - -// defaultConfig maps One CLI template ids to edgeone.json hints. -func defaultConfig(templateID, projectName string) map[string]any { - out := map[string]any{} - if projectName != "" { - out["projectName"] = projectName - } - if outputDir := defaultOutputDir(templateID); outputDir != "" { - out["outputDir"] = outputDir - } - return out -} - -func defaultOutputDir(templateID string) string { - switch templateID { - case "react-spa", "astro-site", "starlight-docs": - return "dist" - case "nextjs-app": - // Next.js on EdgeOne typically goes through edge-runtime build; - // `.next` is the default output. - return ".next" - default: - return "" - } -} diff --git a/packages/cli/internal/adapters/deploy/edgeone/sync_test.go b/packages/cli/internal/adapters/deploy/edgeone/sync_test.go deleted file mode 100644 index ef27d5ca..00000000 --- a/packages/cli/internal/adapters/deploy/edgeone/sync_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package edgeone - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestShouldSyncTrueWhenMissing(t *testing.T) { - if !ShouldSync(t.TempDir()) { - t.Errorf("ShouldSync on empty dir = false, want true") - } -} - -func TestShouldSyncFalseWhenPresent(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, EdgeOneConfigFilename), []byte("{}\n"), 0o644); err != nil { - t.Fatalf("seed edgeone.json: %v", err) - } - if ShouldSync(dir) { - t.Errorf("ShouldSync with existing edgeone.json = true, want false") - } -} - -func TestSyncWritesProjectNameAndOutputDir(t *testing.T) { - for _, tpl := range []string{"react-spa", "astro-site", "starlight-docs"} { - t.Run(tpl, func(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, tpl, "demo-eo"); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readEdgeOneJSON(t, dir) - want := map[string]any{"projectName": "demo-eo", "outputDir": "dist"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("edgeone.json = %v, want %v", got, want) - } - }) - } -} - -func TestSyncWritesNextOutputForNextTemplate(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "nextjs-app", "demo-eo"); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readEdgeOneJSON(t, dir) - if got["outputDir"] != ".next" { - t.Errorf("outputDir = %v, want .next", got["outputDir"]) - } -} - -func TestSyncWritesMinimalConfigForUnknownTemplate(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "some-unknown-template", ""); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readEdgeOneJSON(t, dir) - if len(got) != 0 { - t.Fatalf("unknown template + empty projectName should produce empty config, got %v", got) - } -} - -func TestSyncIsIdempotent(t *testing.T) { - dir := t.TempDir() - pre := []byte(`{"projectName":"custom"}` + "\n") - if err := os.WriteFile(filepath.Join(dir, EdgeOneConfigFilename), pre, 0o644); err != nil { - t.Fatalf("seed: %v", err) - } - if err := Sync(dir, "react-spa", "different"); err != nil { - t.Fatalf("Sync: %v", err) - } - raw, err := os.ReadFile(filepath.Join(dir, EdgeOneConfigFilename)) - if err != nil { - t.Fatalf("read: %v", err) - } - if string(raw) != string(pre) { - t.Fatalf("Sync overwrote existing edgeone.json:\nbefore: %s\nafter: %s", pre, raw) - } -} - -func readEdgeOneJSON(t *testing.T, dir string) map[string]any { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, EdgeOneConfigFilename)) - if err != nil { - t.Fatalf("read edgeone.json: %v", err) - } - var v map[string]any - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("unmarshal edgeone.json: %v", err) - } - return v -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/config.go b/packages/cli/internal/adapters/deploy/kustomize/config.go deleted file mode 100644 index ffcf45f0..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/config.go +++ /dev/null @@ -1,69 +0,0 @@ -package kustomize - -import ( - "encoding/json" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// ProjectConfig is the typed view over -// `projects[i].domains.deploy.config` when the per-project deploy backend -// is Kustomize. Env names the deploy target environment, drawn from -// manifest.environments.names (typically "dev" / "staging" / "prod"). When -// non-empty, the kustomize backend derives the overlay path as -// `kustomize/overlays/` (relative to projectRoot), overriding the -// workspace-shared `manifest.domains.deploy.config.kustomizationPath`. -type ProjectConfig struct { - Env string `json:"env,omitempty"` -} - -// DecodeProjectConfig pulls the Kustomize-specific config blob out of -// the manifest's per-project deploy section. Returns (nil, nil) when no -// project with that name has a Kustomize deploy section configured. -func DecodeProjectConfig(m *workspace.Manifest, projectName string) (*ProjectConfig, error) { - if m == nil { - return nil, nil - } - for _, p := range m.Projects { - if p.Name != projectName { - continue - } - if p.Domains == nil || p.Domains.Deploy == nil || p.Domains.Deploy.Kind != workspace.DeployBackendKustomize { - return nil, nil - } - if len(p.Domains.Deploy.Config) == 0 { - return &ProjectConfig{}, nil - } - var cfg ProjectConfig - if err := json.Unmarshal(p.Domains.Deploy.Config, &cfg); err != nil { - return nil, err - } - return &cfg, nil - } - return nil, nil -} - -// EncodeProjectConfig writes cfg back into projects[i].domains.deploy.config -// (in memory), creating the deploy section if necessary. Caller persists -// via WriteManifest. -func EncodeProjectConfig(p *workspace.ManifestProject, cfg *ProjectConfig) error { - if p == nil { - return nil - } - if p.Domains == nil { - p.Domains = &workspace.ProjectDomains{} - } - if p.Domains.Deploy == nil { - p.Domains.Deploy = &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendKustomize} - } - if cfg == nil { - p.Domains.Deploy.Config = nil - return nil - } - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - p.Domains.Deploy.Config = raw - return nil -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/consts.go b/packages/cli/internal/adapters/deploy/kustomize/consts.go deleted file mode 100644 index 5fdb6c86..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/consts.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package kustomizeplugin generates a Kustomize base + overlays -// structure under kustomize/. base/ holds one .yaml per -// subproject (Deployment + Service joined by ---); kustomize/base/ -// kustomization.yaml lists every resource between sentinel markers, -// so adding a new workload is an idempotent append. Overlays for dev / -// prod are written once and never touched again. -package kustomize - -import ( - _ "embed" - "text/template" -) - -const ( - startMarker = " # one-cli:resources:start" - endMarker = " # one-cli:resources:end" -) - -// defaultOverlay is the path used when the profile doesn't pin one. -// prod is the safe default; users targeting dev/staging set -// KustomizationPath in their profile. -const defaultOverlay = "kustomize/overlays/prod" - -//go:embed templates/deployment.yaml.tmpl -var deploymentTplRaw string - -//go:embed templates/overlay-dev.yaml.tmpl -var overlayDevRaw string - -//go:embed templates/overlay-staging.yaml.tmpl -var overlayStagingRaw string - -//go:embed templates/overlay-prod.yaml.tmpl -var overlayProdRaw string - -var deploymentTpl = template.Must(template.New("deployment").Parse(deploymentTplRaw)) diff --git a/packages/cli/internal/adapters/deploy/kustomize/ops.go b/packages/cli/internal/adapters/deploy/kustomize/ops.go deleted file mode 100644 index 5a5b59b9..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/ops.go +++ /dev/null @@ -1,176 +0,0 @@ -package kustomize - -// ops.go exposes the Render / Apply operations as ordinary package -// functions with package-local types. The plugin wrapper in verbs.go -// adapts the pkg/plugin shapes onto these. - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" -) - -// Endpoint is the kustomize-relevant slice of a deploy profile: -// kubeconfig path/context + namespace + (optional) kustomization path -// override. Empty zero value is valid (defaults apply). -type Endpoint struct { - KubeconfigPath string - KubeconfigContext string - Namespace string - KustomizationPath string -} - -// Stable JSON envelope schema string for the kustomize apply operation. -const SchemaApply = "one-cli/deploy-apply/v1" - -// ApplyInput addresses Apply. -type ApplyInput struct { - ProjectRoot string - Endpoint Endpoint - DryRun bool -} - -// ApplyResult is the Apply envelope. -type ApplyResult struct { - Schema string `json:"schema"` - Argv []string `json:"argv"` - CommandLines []string `json:"command_lines,omitempty"` - DryRun bool `json:"dry_run"` -} - -// Apply runs `kubectl apply -k ` with --context / --namespace -// from the resolved profile. DryRun returns the argv without executing -// kubectl, so it is safe on machines without cluster access. -func Apply(ctx context.Context, in ApplyInput) (*ApplyResult, error) { - overlay := overlayPath(in.Endpoint, in.ProjectRoot) - argv := []string{"kubectl", "apply", "-k", overlay} - argv = append(argv, kubectlGlobalArgs(in.Endpoint)...) - if ns := strings.TrimSpace(in.Endpoint.Namespace); ns != "" { - argv = append(argv, "--namespace", ns) - } - if in.DryRun { - argv = append(argv, "--dry-run=client") - return &ApplyResult{ - Schema: SchemaApply, - Argv: argv, - CommandLines: dryRunCommandLines(in.Endpoint, argv), - DryRun: true, - }, nil - } - if _, err := exec.LookPath("kubectl"); err != nil { - return nil, missingKubectlErr() - } - if err := ensureNamespace(ctx, in.Endpoint); err != nil { - return nil, err - } - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("`%s` 失败:%v", strings.Join(argv, " "), err)) - } - return &ApplyResult{Schema: SchemaApply, Argv: argv, DryRun: in.DryRun}, nil -} - -func dryRunCommandLines(ep Endpoint, applyArgs []string) []string { - lines := []string{} - if ns := strings.TrimSpace(ep.Namespace); ns != "" { - lines = append(lines, - kubectlCommandLine(namespaceCreateArgs(ep, ns))+" | "+kubectlCommandLine(namespaceApplyArgs(ep)), - ) - } - lines = append(lines, strings.Join(applyArgs, " ")) - return lines -} - -func kubectlCommandLine(args []string) string { - if len(args) == 0 { - return "kubectl" - } - return "kubectl " + strings.Join(args, " ") -} - -func kubectlGlobalArgs(ep Endpoint) []string { - args := []string{} - if p := strings.TrimSpace(ep.KubeconfigPath); p != "" { - args = append(args, "--kubeconfig", p) - } - if c := strings.TrimSpace(ep.KubeconfigContext); c != "" { - args = append(args, "--context", c) - } - return args -} - -func ensureNamespace(ctx context.Context, ep Endpoint) error { - ns := strings.TrimSpace(ep.Namespace) - if ns == "" { - return nil - } - createArgs := namespaceCreateArgs(ep, ns) - createCmd := platformprocess.CommandContext(ctx, "kubectl", createArgs...) - var manifest bytes.Buffer - var createErr bytes.Buffer - createCmd.Stdout = &manifest - createCmd.Stderr = &createErr - if err := createCmd.Run(); err != nil { - msg := strings.TrimSpace(createErr.String()) - if msg == "" { - msg = err.Error() - } - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("`kubectl %s` 失败:%s", strings.Join(createArgs, " "), msg)) - } - - applyArgs := namespaceApplyArgs(ep) - applyCmd := platformprocess.CommandContext(ctx, "kubectl", applyArgs...) - applyCmd.Stdin = &manifest - applyCmd.Stdout = os.Stdout - var applyErr bytes.Buffer - applyCmd.Stderr = &applyErr - if err := applyCmd.Run(); err != nil { - msg := strings.TrimSpace(applyErr.String()) - if msg == "" { - msg = err.Error() - } - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("`kubectl %s` 失败:%s", strings.Join(applyArgs, " "), msg)) - } - return nil -} - -func namespaceCreateArgs(ep Endpoint, ns string) []string { - args := append([]string{}, kubectlGlobalArgs(ep)...) - return append(args, "create", "namespace", ns, "--dry-run=client", "-o", "yaml") -} - -func namespaceApplyArgs(ep Endpoint) []string { - args := append([]string{}, kubectlGlobalArgs(ep)...) - return append(args, "apply", "-f", "-") -} - -// overlayPath resolves which kustomize overlay to apply. Profile's -// KustomizationPath wins; otherwise the prod overlay convention. -// Path is interpreted relative to projectRoot when not absolute. -func overlayPath(ep Endpoint, projectRoot string) string { - path := defaultOverlay - if strings.TrimSpace(ep.KustomizationPath) != "" { - path = ep.KustomizationPath - } - if filepath.IsAbs(path) { - return path - } - return filepath.Join(projectRoot, filepath.FromSlash(path)) -} - -func missingKubectlErr() error { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - "未在 PATH 中找到 kubectl。装一个:brew install kubectl(macOS),或参考 https://kubernetes.io/docs/tasks/tools/") -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/ops_test.go b/packages/cli/internal/adapters/deploy/kustomize/ops_test.go deleted file mode 100644 index 2fa5fcb7..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/ops_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package kustomize - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestApplyEnsuresNamespaceBeforeKustomizeApply(t *testing.T) { - tmp := t.TempDir() - overlay := filepath.Join(tmp, "kustomize", "overlays", "prod") - if err := os.MkdirAll(overlay, 0o755); err != nil { - t.Fatalf("mkdir overlay: %v", err) - } - logPath := filepath.Join(tmp, "kubectl.log") - installApplyFakeKubectl(t, tmp, logPath) - - _, err := Apply(context.Background(), ApplyInput{ - ProjectRoot: tmp, - Endpoint: Endpoint{ - KubeconfigPath: filepath.Join(tmp, "kubeconfig.yaml"), - KubeconfigContext: "local", - Namespace: "demo-123", - KustomizationPath: "kustomize/overlays/prod", - }, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - - raw, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read fake kubectl log: %v", err) - } - got := string(raw) - if runtime.GOOS == "windows" { - got = strings.ReplaceAll(got, "\"", "") - } - wants := []string{ - "--kubeconfig " + filepath.Join(tmp, "kubeconfig.yaml") + " --context local create namespace demo-123 --dry-run=client -o yaml", - "--kubeconfig " + filepath.Join(tmp, "kubeconfig.yaml") + " --context local apply -f -", - "apply -k " + overlay + " --kubeconfig " + filepath.Join(tmp, "kubeconfig.yaml") + " --context local --namespace demo-123", - } - for _, want := range wants { - if !strings.Contains(got, want) { - t.Fatalf("kubectl log missing %q:\n%s", want, got) - } - } -} - -func TestApplyAcceptsStagingOverlay(t *testing.T) { - tmp := t.TempDir() - overlay := filepath.Join(tmp, "kustomize", "overlays", "staging") - if err := os.MkdirAll(overlay, 0o755); err != nil { - t.Fatalf("mkdir overlay: %v", err) - } - logPath := filepath.Join(tmp, "kubectl.log") - installApplyFakeKubectl(t, tmp, logPath) - - _, err := Apply(context.Background(), ApplyInput{ - ProjectRoot: tmp, - Endpoint: Endpoint{ - KubeconfigPath: filepath.Join(tmp, "kubeconfig.yaml"), - KubeconfigContext: "local", - Namespace: "demo-123", - KustomizationPath: "kustomize/overlays/staging", - }, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - - raw, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read fake kubectl log: %v", err) - } - got := string(raw) - if runtime.GOOS == "windows" { - got = strings.ReplaceAll(got, "\"", "") - } - if !strings.Contains(got, "apply -k "+overlay) { - t.Fatalf("kubectl log missing staging overlay apply:\n%s", got) - } -} - -func TestApplyDryRunIncludesNamespaceEnsureCommandLines(t *testing.T) { - tmp := t.TempDir() - overlay := filepath.Join(tmp, "kustomize", "overlays", "prod") - if err := os.MkdirAll(overlay, 0o755); err != nil { - t.Fatalf("mkdir overlay: %v", err) - } - kubeconfig := filepath.Join(tmp, "kubeconfig.yaml") - - res, err := Apply(context.Background(), ApplyInput{ - ProjectRoot: tmp, - Endpoint: Endpoint{ - KubeconfigPath: kubeconfig, - KubeconfigContext: "local", - Namespace: "demo-123", - KustomizationPath: "kustomize/overlays/prod", - }, - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply dry-run: %v", err) - } - - wants := []string{ - "kubectl --kubeconfig " + kubeconfig + " --context local create namespace demo-123 --dry-run=client -o yaml | kubectl --kubeconfig " + kubeconfig + " --context local apply -f -", - "kubectl apply -k " + overlay + " --kubeconfig " + kubeconfig + " --context local --namespace demo-123 --dry-run=client", - } - if len(res.CommandLines) != len(wants) { - t.Fatalf("command lines = %#v, want %d lines", res.CommandLines, len(wants)) - } - for i, want := range wants { - if res.CommandLines[i] != want { - t.Fatalf("command line %d = %q, want %q", i, res.CommandLines[i], want) - } - } -} - -func installApplyFakeKubectl(t *testing.T, dir, logPath string) { - t.Helper() - bin := filepath.Join(dir, "bin") - if err := os.MkdirAll(bin, 0o755); err != nil { - t.Fatalf("mkdir fake bin: %v", err) - } - if runtime.GOOS == "windows" { - path := filepath.Join(bin, "kubectl.cmd") - body := "@echo off\r\n" + - "setlocal\r\n" + - ">>\"%KUBECTL_LOG%\" echo %*\r\n" + - "if \"%~5\"==\"create\" (\r\n" + - " echo apiVersion: v1\r\n" + - " echo kind: Namespace\r\n" + - " echo metadata:\r\n" + - " echo name: demo-123\r\n" + - " exit /b 0\r\n" + - ")\r\n" + - "if \"%~5\"==\"apply\" (\r\n" + - " echo namespace/demo-123 configured\r\n" + - " exit /b 0\r\n" + - ")\r\n" + - "if \"%~1\"==\"apply\" (\r\n" + - " echo deployment.apps/api configured\r\n" + - " exit /b 0\r\n" + - ")\r\n" + - "exit /b 1\r\n" - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake kubectl: %v", err) - } - t.Setenv("KUBECTL_LOG", logPath) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - return - } - path := filepath.Join(bin, "kubectl") - body := `#!/bin/sh -echo "$@" >> "$KUBECTL_LOG" -case "$*" in - *" create namespace "*) - printf 'apiVersion: v1\nkind: Namespace\nmetadata:\n name: demo-123\n' - ;; - *" apply -f -"*) - cat >/dev/null - printf 'namespace/demo-123 configured\n' - ;; - *"apply -k "*) - printf 'deployment.apps/api configured\n' - ;; - *) - printf 'unexpected kubectl %s\n' "$*" >&2 - exit 1 - ;; -esac -` - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake kubectl: %v", err) - } - t.Setenv("KUBECTL_LOG", logPath) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/provider.go b/packages/cli/internal/adapters/deploy/kustomize/provider.go deleted file mode 100644 index dde3a29e..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/provider.go +++ /dev/null @@ -1,486 +0,0 @@ -package kustomize - -// provider.go is the kustomize backend's adapter to the deploy.Provider -// interface. Apply orchestrates the kustomize-specific -// pre-deploy flow that used to live in deploycmd: -// 1. require a deploy/kustomize profile (kubeconfig + context) -// 2. if the project has container/docker enabled, build + push its -// image (tag chosen interactively in TTY, --tag flag in CI) -// 3. sync the kustomize overlay so it points at the just-pushed image -// 4. run `kubectl apply -k ` -// -// The container pre-build lives here, not in deploycmd, because it's -// a kustomize concern: vercel/s3 don't need a container artifact. - -import ( - "context" - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/container/docker" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "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/prompt" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -// providerImpl is the deploy.Provider for "kustomize". Lives here so -// kustomize's container pre-build, overlay sync, and kubectl apply all -// stay in one package. -type providerImpl struct { - providerTag func() string -} - -// NewProvider constructs the compiled kustomize adapter. Container pre-builds -// use the same Docker adapter as the container module; the build tag is -// command-scoped state. -func NewProvider(buildTag string) deploy.Provider { - return providerImpl{ - providerTag: func() string { - return buildTag - }, - } -} - -func (providerImpl) ID() string { return workspace.DeployBackendKustomize } - -func (p providerImpl) Apply(ctx context.Context, in deploy.ApplyInput) (*deploy.ApplyResult, error) { - if err := requireK8sDeployProfile(in.Resolved); err != nil { - return nil, err - } - endpoint := endpointFromInput(in) - - // Container pre-build: only fires when the project has - // container/docker enabled in the manifest. Sets the project's - // container.image to the just-pushed reference so kustomize - // overlay sync below can pick it up. - if containerEnabledForProject(in.Manifest, in.Project.Name) { - reg, err := resolveContainerRegistryForDeploy( - in.ProjectRoot, in.Manifest, in.Project.Name, in.Environment, - ) - if err != nil { - return nil, err - } - platform, err := resolveBuildPlatformForDeploy(in.Manifest, endpoint, in.DryRun) - if err != nil { - return nil, err - } - buildTag := "" - if p.providerTag != nil { - buildTag = p.providerTag() - } - imageTag, err := selectDeployImageTag(in.Stdout, in.Manifest, in.Project.Name, buildTag) - if err != nil { - return nil, err - } - if err := p.buildAndPushContainer(ctx, in, imageTag, reg, platform); err != nil { - return nil, err - } - // Re-read manifest because buildAndPushContainer may have - // updated projects[].container.image / .buildVersion. - if !in.DryRun { - m, _ := workspace.ReadManifest(in.ProjectRoot) - in.Manifest = m - } - } - - if !in.DryRun { - if err := syncOverlayTargetForApply(in.ProjectRoot, in.Manifest, endpoint); err != nil { - return nil, err - } - } - - res, err := Apply(ctx, ApplyInput{ - ProjectRoot: in.ProjectRoot, - Endpoint: endpoint, - DryRun: in.DryRun, - }) - if err != nil { - return nil, err - } - if res == nil { - return nil, nil - } - return &deploy.ApplyResult{ - Schema: res.Schema, - Argv: res.Argv, - CommandLines: res.CommandLines, - DryRun: res.DryRun, - }, nil -} - -// endpointFromInput collects the kustomize Endpoint from the resolved -// profile + manifest. The split: profile holds credentials / context -// (machine-level), manifest holds the deploy target (workspace-level -// kustomizationPath, namespace; per-project env name). -// -// Overlay path priority: -// 1. per-project `projects[i].deploy.kustomize.env` → `kustomize/overlays/` -// 2. workspace `manifest.deploy.kustomizationPath` -// 3. package default (`kustomize/overlays/prod`, via ops.overlayPath) -func endpointFromInput(in deploy.ApplyInput) Endpoint { - ep := Endpoint{ - Namespace: workspace.DeployNamespace(in.Manifest), - KustomizationPath: workspace.DeployKustomizationPath(in.Manifest), - } - if envName := envForProject(in.Manifest, in.Project.Name); envName != "" { - ep.KustomizationPath = "kustomize/overlays/" + envName - } - if in.Resolved != nil && in.Resolved.Profile.Kustomize != nil { - ep.KubeconfigPath = in.Resolved.Profile.Kustomize.KubeconfigPath - ep.KubeconfigContext = in.Resolved.Profile.Kustomize.KubeconfigContext - } - return ep -} - -// envForProject reads projects[i].domains.deploy.config.env. Empty when -// the manifest does not pin a value; callers then fall back to the -// workspace-level kustomizationPath or the package default. -func envForProject(m *workspace.Manifest, projectName string) string { - cfg, _ := DecodeProjectConfig(m, projectName) - if cfg == nil { - return "" - } - return strings.TrimSpace(cfg.Env) -} - -func requireK8sDeployProfile(resolved *profile.Resolved) error { - if resolved != nil && resolved.Profile.Kustomize != nil { - return nil - } - return cliErrors.New(cliErrors.PROFILE_NONE_CONFIGURED, - "还没有配置 Kubernetes 部署目标。请先执行 `one configure add deploy/kustomize --profile --use`。"). - WithRemediation(output.Remediation{ - Action: "setup-k8s-deploy", - Hint: "选择 kubeconfig 和 context", - Command: "one configure add deploy/kustomize --profile --use", - }) -} - -func containerEnabledForProject(m *workspace.Manifest, projectName string) bool { - if m == nil { - return false - } - enabled, _ := workspace.ContainerForProject(m, projectName) - return enabled -} - -// resolveContainerRegistryForDeploy fetches the per-project container -// registry endpoint by dispatching to docker.ResolveRegistry. The -// project's container.kind (manifest field, default "docker") picks -// which of the four kinds to resolve against. -func resolveContainerRegistryForDeploy( - projectRoot string, - manifest *workspace.Manifest, - subproject, environment string, -) (*container.Registry, error) { - kind := workspace.ContainerKindForProject(manifest, subproject) - environment = strings.TrimSpace(environment) - if environment == "" { - environment = envForProject(manifest, subproject) - } - if environment == "" { - environment = "prod" - } - return docker.ResolveRegistry(docker.ResolveRegistryInput{ - ProjectRoot: projectRoot, - Kind: kind, - Subproject: subproject, - Environment: environment, - RequireRegistry: true, - }) -} - -func resolveBuildPlatformForDeploy(m *workspace.Manifest, endpoint Endpoint, allowCached bool) (string, error) { - platform := detectKubeNodePlatform(endpoint.KubeconfigPath, endpoint.KubeconfigContext) - if platform != "" { - return platform, nil - } - if allowCached { - if platform := strings.TrimSpace(workspace.ContainerPlatform(m)); platform != "" { - return platform, nil - } - } - cmdText := "kubectl get nodes -o wide" - if endpoint.KubeconfigPath != "" { - cmdText = "kubectl --kubeconfig " + endpoint.KubeconfigPath + " get nodes -o wide" - } - if endpoint.KubeconfigContext != "" { - if endpoint.KubeconfigPath != "" { - cmdText = "kubectl --kubeconfig " + endpoint.KubeconfigPath + " --context " + endpoint.KubeconfigContext + " get nodes -o wide" - } else { - cmdText = "kubectl --context " + endpoint.KubeconfigContext + " get nodes -o wide" - } - } - return "", cliErrors.New(cliErrors.K8S_PLATFORM_UNDETECTED, - "无法在构建镜像前检测 Kubernetes 节点架构。请先确认 kubeconfig/context/DNS 可用,再重新执行 `one deploy`。"). - WithContext(map[string]any{ - "kubeconfig": endpoint.KubeconfigPath, - "context": endpoint.KubeconfigContext, - }). - WithRemediation(output.Remediation{ - Action: "check-k8s", - Hint: "确认当前部署目标可以访问,并能列出节点", - Command: cmdText, - }) -} - -func detectKubeNodePlatform(kubeconfigPath, kubeconfigContext string) string { - args := []string{} - if kubeconfigPath = strings.TrimSpace(kubeconfigPath); kubeconfigPath != "" { - args = append(args, "--kubeconfig", kubeconfigPath) - } - if ctx := strings.TrimSpace(kubeconfigContext); ctx != "" { - args = append(args, "--context", ctx) - } - args = append(args, "get", "nodes", "-o", `jsonpath={range .items[*]}{.status.nodeInfo.architecture}{"\n"}{end}`) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - out, err := platformprocess.CommandContext(ctx, "kubectl", args...).Output() - if err != nil { - return "" - } - architectures := map[string]struct{}{} - for _, line := range strings.Fields(string(out)) { - architectures[strings.TrimSpace(line)] = struct{}{} - } - if len(architectures) != 1 { - return "" - } - for arch := range architectures { - switch arch { - case "amd64": - return "linux/amd64" - case "arm64": - return "linux/arm64" - } - } - return "" -} - -func (p providerImpl) buildAndPushContainer(ctx context.Context, in deploy.ApplyInput, tag string, reg *container.Registry, platform string) error { - root := in.ProjectRoot - dryRun := in.DryRun - stdout := in.Stdout - if stdout == nil { - stdout = io.Writer(os.Stdout) - } - buildRes, err := docker.Build(ctx, container.BuildInput{ - ProjectRoot: root, - Project: in.Project.Name, - TargetNames: []string{in.Project.Name}, - Tag: tag, - Platform: platform, - DryRun: dryRun, - Registry: reg, - }) - if err != nil { - return err - } - effectiveTag := strings.TrimSpace(tag) - if buildRes != nil { - for _, e := range buildRes.Built { - if dryRun { - fmt.Fprintln(stdout, strings.Join(e.Argv, " ")) - if effectiveTag == "" { - effectiveTag = container.ImageTagVersion(e.Image) - } - continue - } - if err := workspace.SetProjectContainerImage(root, e.Project, e.Image); err != nil { - return err - } - if err := workspace.SetProjectBuildVersion(root, e.Project, container.ImageTagVersion(e.Image)); err != nil { - return err - } - if effectiveTag == "" { - effectiveTag = container.ImageTagVersion(e.Image) - } - } - } - if platform != "" && !dryRun { - if err := workspace.SetWorkspaceContainerPlatform(root, platform); err != nil { - return err - } - } - pushRes, err := docker.Push(ctx, container.PushInput{ - ProjectRoot: root, - Project: in.Project.Name, - TargetNames: []string{in.Project.Name}, - Tag: effectiveTag, - DryRun: dryRun, - Registry: reg, - }) - if err != nil { - return err - } - if pushRes != nil { - for _, e := range pushRes.Pushed { - if dryRun { - fmt.Fprintln(stdout, strings.Join(e.Argv, " ")) - continue - } - if err := workspace.SetProjectContainerImage(root, e.Project, e.Image); err != nil { - return err - } - if err := workspace.SetProjectBuildVersion(root, e.Project, container.ImageTagVersion(e.Image)); err != nil { - return err - } - } - } - return nil -} - -type semverTag struct { - major int - minor int - patch int -} - -// selectDeployImageTag picks the image tag for the next push. Honours -// the explicitTag argument first (CI / scripted callers), then prompts -// the user in TTY, then bails when neither is available. -func selectDeployImageTag(_ io.Writer, m *workspace.Manifest, subprojectName, explicitTag string) (string, error) { - explicitTag = strings.TrimSpace(explicitTag) - if explicitTag != "" { - return normalizeVersionTag(explicitTag), nil - } - if !output.CanPrompt() { - return "", nil - } - current, hasCurrent := currentImageSemverTag(m, subprojectName) - if !hasCurrent { - current = semverTag{} - } - patchTag := formatSemverTag(semverTag{major: current.major, minor: current.minor, patch: current.patch + 1}) - minorTag := formatSemverTag(semverTag{major: current.major, minor: current.minor + 1, patch: 0}) - majorTag := formatSemverTag(semverTag{major: current.major + 1, minor: 0, patch: 0}) - - options := []prompt.Option[string]{} - if hasCurrent { - options = append(options, - prompt.Option[string]{Label: "Current version " + formatSemverTag(current), Value: formatSemverTag(current)}, - prompt.Option[string]{Label: "Patch version " + patchTag, Value: patchTag}, - prompt.Option[string]{Label: "Minor version " + minorTag, Value: minorTag}, - prompt.Option[string]{Label: "Major version " + majorTag, Value: majorTag}, - ) - } else { - options = append(options, - prompt.Option[string]{Label: "Initial minor version " + minorTag, Value: minorTag}, - prompt.Option[string]{Label: "Major version " + majorTag, Value: majorTag}, - prompt.Option[string]{Label: "Patch version " + patchTag, Value: patchTag}, - ) - } - options = append(options, prompt.Option[string]{Label: "Custom version", Value: "__custom__"}) - - selected, err := prompt.Select("Select image version", options) - if err != nil { - return "", err - } - if selected != "__custom__" { - return selected, nil - } - placeholder := minorTag - if hasCurrent { - placeholder = patchTag - } - custom, err := prompt.Text("Image version", placeholder, func(value string) error { - if _, ok := parseSemverTag(value); !ok { - return fmt.Errorf("enter a semver version, e.g. v0.1.0") - } - return nil - }) - if err != nil { - return "", err - } - return normalizeVersionTag(custom), nil -} - -func currentImageSemverTag(m *workspace.Manifest, subprojectName string) (semverTag, bool) { - if m == nil { - return semverTag{}, false - } - if version := workspace.BuildVersionForProject(m, subprojectName); version != "" { - if tag, ok := parseSemverTag(version); ok { - return tag, true - } - } - for _, sub := range m.Projects { - if sub.Name != subprojectName { - continue - } - if sub.Domains == nil || sub.Domains.Container == nil { - continue - } - if tag, ok := parseSemverTag(container.ImageTagVersion(sub.Domains.Container.Image)); ok { - return tag, true - } - } - if tag, ok := parseSemverTag(workspace.DefaultBuildVersion); ok { - return tag, true - } - return semverTag{}, false -} - -func parseSemverTag(value string) (semverTag, bool) { - value = strings.TrimSpace(value) - value = strings.TrimPrefix(strings.TrimPrefix(value, "v"), "V") - parts := strings.Split(value, ".") - if len(parts) != 3 { - return semverTag{}, false - } - nums := make([]int, 3) - for i, part := range parts { - if part == "" { - return semverTag{}, false - } - n, err := strconv.Atoi(part) - if err != nil || n < 0 { - return semverTag{}, false - } - nums[i] = n - } - return semverTag{major: nums[0], minor: nums[1], patch: nums[2]}, true -} - -func normalizeVersionTag(value string) string { - parsed, ok := parseSemverTag(value) - if !ok { - return strings.TrimSpace(value) - } - return formatSemverTag(parsed) -} - -func formatSemverTag(v semverTag) string { - return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) -} - -// syncOverlayTargetForApply rewrites the prod overlay's image tags to -// match the just-pushed images. Called only when the workspace runs at -// least one kustomize-backed project. -func syncOverlayTargetForApply(root string, m *workspace.Manifest, endpoint Endpoint) error { - images := map[string]string{} - if m != nil { - for _, sub := range m.Projects { - if sub.Domains == nil { - continue - } - if sub.Domains.Deploy == nil || sub.Domains.Deploy.Kind != workspace.DeployBackendKustomize { - continue - } - if sub.Domains.Container == nil || strings.TrimSpace(sub.Domains.Container.Image) == "" { - continue - } - images[sub.Name] = sub.Domains.Container.Image - } - } - return SyncOverlayTarget(root, endpoint.KustomizationPath, endpoint.Namespace, images) -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/provider_test.go b/packages/cli/internal/adapters/deploy/kustomize/provider_test.go deleted file mode 100644 index 994eafa3..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/provider_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package kustomize - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -func TestNormalizeVersionTag(t *testing.T) { - tests := map[string]string{ - "0.1.0": "v0.1.0", - "v1.2.3": "v1.2.3", - "V2.0.1": "v2.0.1", - "custom": "custom", - } - for in, want := range tests { - if got := normalizeVersionTag(in); got != want { - t.Errorf("normalizeVersionTag(%q) = %q, want %q", in, got, want) - } - } -} - -func TestCurrentImageSemverTag(t *testing.T) { - m := manifestWithImage("api", "ghcr.io/acme/api:v0.3.4") - got, ok := currentImageSemverTag(m, "api") - if !ok { - t.Fatal("expected semver image tag") - } - if got.major != 0 || got.minor != 3 || got.patch != 4 { - t.Fatalf("tag = %#v, want v0.3.4", got) - } -} - -func TestResolveBuildPlatformForDeployCanUseCachedPlatformForDryRun(t *testing.T) { - platformCfg, _ := json.Marshal(map[string]string{"platform": "linux/amd64"}) - m := &workspace.Manifest{ - Domains: &workspace.WorkspaceDomains{ - Container: &workspace.BackendRef{Kind: "docker", Config: platformCfg}, - }, - } - got, err := resolveBuildPlatformForDeploy(m, Endpoint{ - KubeconfigPath: "/does/not/exist", - KubeconfigContext: "missing", - }, true) - if err != nil { - t.Fatalf("resolveBuildPlatformForDeploy: %v", err) - } - if got != "linux/amd64" { - t.Fatalf("platform = %q, want linux/amd64", got) - } -} - -func manifestWithImage(name, image string) *workspace.Manifest { - return &workspace.Manifest{ - Projects: []workspace.ManifestProject{ - { - Name: name, - Domains: &workspace.ProjectDomains{ - Container: &workspace.ProjectContainerOverride{Image: image}, - }, - }, - }, - } -} - -// envForProject returns the per-project Kustomize Env pin, or empty when -// the manifest does not declare one. -func TestEnvForProject(t *testing.T) { - cases := []struct { - name string - m *workspace.Manifest - want string - }{ - {name: "nil manifest", m: nil, want: ""}, - {name: "missing project", m: &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "other"}}}, want: ""}, - { - name: "project without deploy section", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "api"}, - }}, - want: "", - }, - { - name: "project without kustomize config", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "api", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "kustomize"}, - }}, - }}, - want: "", - }, - { - name: "explicit env=staging returns staging", - m: manifestWithKustomizeEnv(t, "staging"), - want: "staging", - }, - { - name: "env=prod returns prod (trimmed)", - m: manifestWithKustomizeEnv(t, " prod "), - want: "prod", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := envForProject(tc.m, "api"); got != tc.want { - t.Fatalf("envForProject = %q, want %q", got, tc.want) - } - }) - } -} - -// endpointFromInput must let a per-project Kustomize Env override the -// workspace-level kustomizationPath, so a `--env staging` flag (which -// applyEnvOverride writes into the deploy config blob) selects the -// staging overlay even when the workspace manifest pinned -// kustomizationPath to prod. -func TestEndpointFromInputEnvOverridesWorkspaceKustomizationPath(t *testing.T) { - wsCfg, _ := json.Marshal(map[string]string{"kustomizationPath": "kustomize/overlays/prod"}) - m := manifestWithKustomizeEnv(t, "staging") - m.Domains = &workspace.WorkspaceDomains{ - Deploy: &workspace.BackendRef{Kind: "kustomize", Config: wsCfg}, - } - ep := endpointFromInput(deploy.ApplyInput{ - Project: workspace.Project{Name: "api", RelativeDir: "services/api"}, - Manifest: m, - }) - if ep.KustomizationPath != "kustomize/overlays/staging" { - t.Fatalf("KustomizationPath = %q, want kustomize/overlays/staging", ep.KustomizationPath) - } -} - -// When the per-project deploy config carries no env, endpointFromInput -// falls back to the workspace-level kustomizationPath. -func TestEndpointFromInputFallsBackToWorkspaceKustomizationPath(t *testing.T) { - wsCfg, _ := json.Marshal(map[string]string{"kustomizationPath": "infra/overlays/canary"}) - m := &workspace.Manifest{ - Domains: &workspace.WorkspaceDomains{ - Deploy: &workspace.BackendRef{Kind: "kustomize", Config: wsCfg}, - }, - Projects: []workspace.ManifestProject{ - {Name: "api", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "kustomize"}, - }}, - }, - } - ep := endpointFromInput(deploy.ApplyInput{ - Project: workspace.Project{Name: "api", RelativeDir: "services/api"}, - Manifest: m, - }) - if ep.KustomizationPath != "infra/overlays/canary" { - t.Fatalf("KustomizationPath = %q, want infra/overlays/canary", ep.KustomizationPath) - } -} - -func TestContainerRegistryUsesApplyEnvironmentInsteadOfDiskDeployEnvironment(t *testing.T) { - configRoot := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", configRoot) - t.Setenv("HOME", configRoot) - root := t.TempDir() - diskManifest := &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "workspace-id", Name: "demo"}, - Environments: &workspace.Environments{Names: []string{"dev", "prod"}, Default: "dev"}, - Projects: []workspace.ManifestProject{{ - Name: "api", RelativeDir: "services/api", Toolchain: "go", - Domains: &workspace.ProjectDomains{ - Container: &workspace.ProjectContainerOverride{Kind: workspace.ContainerBackendDocker}, - Deploy: &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendKustomize}, - }, - }}, - } - if err := EncodeProjectConfig(&diskManifest.Projects[0], &ProjectConfig{Env: "prod"}); err != nil { - t.Fatal(err) - } - if err := workspace.WriteManifest(root, diskManifest); err != nil { - t.Fatal(err) - } - manifestPath := filepath.Join(root, workspace.ManifestFilename) - before, err := os.ReadFile(manifestPath) - if err != nil { - t.Fatal(err) - } - - for _, entry := range []struct { - environment, name, registry string - }{ - {environment: "dev", name: "development", registry: "dev.registry.example.com"}, - {environment: "prod", name: "production", registry: "prod.registry.example.com"}, - } { - if _, err := profile.Upsert( - profile.DomainContainer, - workspace.ContainerBackendDocker, - entry.name, - profile.Profile{ - Backend: workspace.ContainerBackendDocker, - Container: &profile.ContainerProfile{ - Registry: entry.registry, - Credentials: &profile.ContainerCredentials{ - Username: entry.name, Password: "secret", - }, - }, - }, - false, - ); err != nil { - t.Fatal(err) - } - if err := profile.BindEnvironmentProfile( - "workspace-id", "demo", root, "api", entry.environment, - profile.DomainContainer, workspace.ContainerBackendDocker, entry.name, - ); err != nil { - t.Fatal(err) - } - } - - inMemoryManifest, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if err := EncodeProjectConfig(&inMemoryManifest.Projects[0], &ProjectConfig{Env: "dev"}); err != nil { - t.Fatal(err) - } - registry, err := resolveContainerRegistryForDeploy( - root, inMemoryManifest, "api", "dev", - ) - if err != nil { - t.Fatal(err) - } - if registry.ProfileName != "development" || - registry.ProfileSource != "workspace-project-environment" || - registry.Registry != "dev.registry.example.com" { - t.Fatalf("registry = %#v", registry) - } - after, err := os.ReadFile(manifestPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(after, before) { - t.Fatal("container Profile resolution persisted the CLI environment override") - } -} - -func manifestWithKustomizeEnv(t *testing.T, env string) *workspace.Manifest { - t.Helper() - p := workspace.ManifestProject{Name: "api"} - if err := EncodeProjectConfig(&p, &ProjectConfig{Env: env}); err != nil { - t.Fatalf("EncodeProjectConfig: %v", err) - } - return &workspace.Manifest{Projects: []workspace.ManifestProject{p}} -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/sync.go b/packages/cli/internal/adapters/deploy/kustomize/sync.go deleted file mode 100644 index 169ef7f9..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/sync.go +++ /dev/null @@ -1,251 +0,0 @@ -package kustomize - -// sync.go is the package-local Sync entry point: scaffolds the -// per-workload Kustomize tree (base/.yaml + kustomization.yaml -// + overlays/dev|staging|prod). plugin.go's Sync method delegates here. - -import ( - "bytes" - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "text/template" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/shared" - "gopkg.in/yaml.v3" -) - -// Sync scaffolds the Kustomize tree under /kustomize for -// the given workload. Idempotent: re-running on an already-configured -// workspace is a no-op. -// -// containerPort is the port the workload listens on inside the container; -// pass 0 to use the default (8080). -func Sync(workspaceRoot, workloadName string, containerPort int) error { - if workloadName == "" { - return nil - } - root := filepath.Join(workspaceRoot, "kustomize") - baseDir := filepath.Join(root, "base") - if err := os.MkdirAll(baseDir, 0o755); err != nil { - return err - } - - port := containerPort - if port == 0 { - port = 8080 - } - - // 1. write per-workload manifest under base/.yaml — never - // overwrite existing (preserve user edits). - workloadFile := filepath.Join(baseDir, workloadName+".yaml") - if err := writeIfMissing(workloadFile, deploymentTpl, struct { - WorkloadName string - ContainerPort int - }{ - WorkloadName: workloadName, - ContainerPort: port, - }); err != nil { - return err - } - - // 2. ensure base/kustomization.yaml lists this workload between - // sentinel markers. - if err := appendBaseResource(baseDir, workloadName); err != nil { - return err - } - - // 3. write overlays (dev / staging / prod) — created once, never touched. - if err := writeRawIfMissing(filepath.Join(root, "overlays", "dev", "kustomization.yaml"), overlayDevRaw); err != nil { - return err - } - if err := writeRawIfMissing(filepath.Join(root, "overlays", "staging", "kustomization.yaml"), overlayStagingRaw); err != nil { - return err - } - if err := writeRawIfMissing(filepath.Join(root, "overlays", "prod", "kustomization.yaml"), overlayProdRaw); err != nil { - return err - } - return nil -} - -func appendBaseResource(baseDir, workload string) error { - path := filepath.Join(baseDir, "kustomization.yaml") - raw, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return err - } - raw = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n\nresources:\n" + startMarker + "\n" + endMarker + "\n") - } - existing := internalcommon.NormalizeNewlines(string(raw)) - withMarkers := ensureResourceMarkers(existing) - - entry := " - " + workload + ".yaml" - if hasResource(withMarkers, entry) { - if withMarkers != existing { - return os.WriteFile(path, []byte(internalcommon.EnsureTrailingNewline(withMarkers)), 0o644) - } - return nil - } - - updated := withMarkers - if strings.Contains(updated, endMarker) { - updated = strings.Replace(updated, endMarker, entry+"\n"+endMarker, 1) - } else { - updated = strings.TrimRight(updated, "\n") + "\n" + entry + "\n" - } - return os.WriteFile(path, []byte(internalcommon.EnsureTrailingNewline(updated)), 0o644) -} - -func ensureResourceMarkers(content string) string { - normalized := internalcommon.NormalizeNewlines(content) - if strings.Contains(normalized, startMarker) && strings.Contains(normalized, endMarker) { - return normalized - } - lines := strings.Split(normalized, "\n") - resourcesIdx := -1 - for i, ln := range lines { - if strings.TrimSpace(ln) == "resources:" { - resourcesIdx = i - break - } - } - if resourcesIdx >= 0 { - newLines := append([]string{}, lines[:resourcesIdx+1]...) - newLines = append(newLines, startMarker, endMarker) - newLines = append(newLines, lines[resourcesIdx+1:]...) - return internalcommon.EnsureTrailingNewline(strings.Join(newLines, "\n")) - } - base := strings.TrimRight(normalized, "\n") - if base == "" { - return "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n\nresources:\n" + startMarker + "\n" + endMarker + "\n" - } - return internalcommon.EnsureTrailingNewline(base + "\n\nresources:\n" + startMarker + "\n" + endMarker) -} - -func hasResource(content, entry string) bool { - for _, line := range strings.Split(content, "\n") { - if strings.TrimRight(line, " \t") == entry { - return true - } - } - return false -} - -func writeIfMissing(path string, tpl *template.Template, data any) error { - if _, err := os.Stat(path); err == nil { - return nil - } else if !errors.Is(err, fs.ErrNotExist) { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - var buf bytes.Buffer - if err := tpl.Execute(&buf, data); err != nil { - return err - } - return os.WriteFile(path, buf.Bytes(), 0o644) -} - -func writeRawIfMissing(path, content string) error { - if _, err := os.Stat(path); err == nil { - return nil - } else if !errors.Is(err, fs.ErrNotExist) { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - return os.WriteFile(path, []byte(content), 0o644) -} - -// SyncOverlayTarget updates the selected overlay with the namespace and -// image overrides resolved by deploy/container flow. It intentionally -// touches only kustomization.yaml fields managed by one-cli and leaves -// resources / user patches intact. -func SyncOverlayTarget(workspaceRoot, overlayRelPath, namespace string, images map[string]string) error { - if strings.TrimSpace(overlayRelPath) == "" { - overlayRelPath = defaultOverlay - } - path := overlayRelPath - if !filepath.IsAbs(path) { - path = filepath.Join(workspaceRoot, filepath.FromSlash(path)) - } - if fi, err := os.Stat(path); err == nil && fi.IsDir() { - path = filepath.Join(path, "kustomization.yaml") - } else if err != nil && errors.Is(err, fs.ErrNotExist) && filepath.Ext(path) == "" { - path = filepath.Join(path, "kustomization.yaml") - } - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return err - } - var doc kustomizationDoc - if err := yaml.Unmarshal(raw, &doc); err != nil { - return err - } - if ns := strings.TrimSpace(namespace); ns != "" { - doc.Namespace = ns - } - for name, image := range images { - name = strings.TrimSpace(name) - image = strings.TrimSpace(image) - if name == "" || image == "" { - continue - } - newName, newTag := splitImageRef(image) - doc.setImage(name, newName, newTag) - } - updated, err := yaml.Marshal(&doc) - if err != nil { - return err - } - return os.WriteFile(path, updated, 0o644) -} - -type kustomizationDoc struct { - APIVersion string `yaml:"apiVersion,omitempty"` - Kind string `yaml:"kind,omitempty"` - Namespace string `yaml:"namespace,omitempty"` - Resources []string `yaml:"resources,omitempty"` - NamePrefix string `yaml:"namePrefix,omitempty"` - Images []kustomizeImageRef `yaml:"images,omitempty"` - Extra map[string]any `yaml:",inline,omitempty"` -} - -type kustomizeImageRef struct { - Name string `yaml:"name"` - NewName string `yaml:"newName,omitempty"` - NewTag string `yaml:"newTag,omitempty"` -} - -func (d *kustomizationDoc) setImage(name, newName, newTag string) { - for i := range d.Images { - if d.Images[i].Name == name { - d.Images[i].NewName = newName - d.Images[i].NewTag = newTag - return - } - } - d.Images = append(d.Images, kustomizeImageRef{ - Name: name, - NewName: newName, - NewTag: newTag, - }) -} - -func splitImageRef(ref string) (string, string) { - if idx := strings.LastIndex(ref, ":"); idx > -1 { - slash := strings.LastIndex(ref, "/") - if idx > slash { - return ref[:idx], ref[idx+1:] - } - } - return ref, "" -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/sync_test.go b/packages/cli/internal/adapters/deploy/kustomize/sync_test.go deleted file mode 100644 index 81d2c4c1..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/sync_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package kustomize - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestSyncScaffoldsAllThreeOverlays(t *testing.T) { - tmp := t.TempDir() - if err := Sync(tmp, "api", 8080); err != nil { - t.Fatalf("Sync: %v", err) - } - for _, env := range []string{"dev", "staging", "prod"} { - path := filepath.Join(tmp, "kustomize", "overlays", env, "kustomization.yaml") - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("missing overlay %s: %v", env, err) - } - got := string(raw) - for _, want := range []string{ - "namespace: " + env, - "namePrefix: " + env + "-", - "app.kubernetes.io/environment: " + env, - "resources:\n - ../../base", - } { - if !strings.Contains(got, want) { - t.Fatalf("overlay %s missing %q:\n%s", env, want, got) - } - } - } -} - -func TestSyncOverlayTargetPreservesUnknownFields(t *testing.T) { - tmp := t.TempDir() - overlay := filepath.Join(tmp, "kustomize", "overlays", "prod") - if err := os.MkdirAll(overlay, 0o755); err != nil { - t.Fatal(err) - } - path := filepath.Join(overlay, "kustomization.yaml") - before := `apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - ../../base -commonLabels: - app.kubernetes.io/managed-by: user -patches: - - path: deployment-patch.yaml -` - if err := os.WriteFile(path, []byte(before), 0o644); err != nil { - t.Fatal(err) - } - - err := SyncOverlayTarget(tmp, "kustomize/overlays/prod", "prod", map[string]string{ - "api": "ghcr.io/acme/api:v0.1.1", - }) - if err != nil { - t.Fatal(err) - } - - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - after := string(raw) - for _, want := range []string{ - "namespace: prod", - "commonLabels:", - "app.kubernetes.io/managed-by: user", - "patches:", - "path: deployment-patch.yaml", - "name: api", - "newName: ghcr.io/acme/api", - "newTag: v0.1.1", - } { - if !strings.Contains(after, want) { - t.Fatalf("updated kustomization missing %q:\n%s", want, after) - } - } -} diff --git a/packages/cli/internal/adapters/deploy/kustomize/templates/deployment.yaml.tmpl b/packages/cli/internal/adapters/deploy/kustomize/templates/deployment.yaml.tmpl deleted file mode 100644 index a9f15f3b..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/templates/deployment.yaml.tmpl +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{.WorkloadName}} - labels: - app: {{.WorkloadName}} -spec: - replicas: 1 - selector: - matchLabels: - app: {{.WorkloadName}} - template: - metadata: - labels: - app: {{.WorkloadName}} - spec: - containers: - - name: {{.WorkloadName}} - image: {{.WorkloadName}}:latest - ports: - - containerPort: {{.ContainerPort}} - protocol: TCP ---- -apiVersion: v1 -kind: Service -metadata: - name: {{.WorkloadName}} -spec: - type: ClusterIP - selector: - app: {{.WorkloadName}} - ports: - - port: {{.ContainerPort}} - targetPort: {{.ContainerPort}} - protocol: TCP - name: http diff --git a/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-dev.yaml.tmpl b/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-dev.yaml.tmpl deleted file mode 100644 index a10e6e78..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-dev.yaml.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: dev - -resources: - - ../../base - -namePrefix: dev- - -commonLabels: - app.kubernetes.io/environment: dev diff --git a/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-prod.yaml.tmpl b/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-prod.yaml.tmpl deleted file mode 100644 index 1cb14a41..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-prod.yaml.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: prod - -resources: - - ../../base - -namePrefix: prod- - -commonLabels: - app.kubernetes.io/environment: prod diff --git a/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-staging.yaml.tmpl b/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-staging.yaml.tmpl deleted file mode 100644 index bdb4a236..00000000 --- a/packages/cli/internal/adapters/deploy/kustomize/templates/overlay-staging.yaml.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: staging - -resources: - - ../../base - -namePrefix: staging- - -commonLabels: - app.kubernetes.io/environment: staging diff --git a/packages/cli/internal/adapters/deploy/s3compat/doc.go b/packages/cli/internal/adapters/deploy/s3compat/doc.go deleted file mode 100644 index 3ba489fd..00000000 --- a/packages/cli/internal/adapters/deploy/s3compat/doc.go +++ /dev/null @@ -1,9 +0,0 @@ -// Package s3compat implements the six S3-protocol-compatible deploy -// backends (deploy/aliyun-oss, deploy/tencent-cos, deploy/aws-s3, -// deploy/minio, deploy/rustfs, deploy/r2). They all share the same -// upload implementation here — only the user-facing id, default -// endpoint/region prompts, and forcePathStyle defaults differ, and -// those are surfaced by configurecmd, not by this package. provider.go -// registers one deploy.Provider per kind so the deploy dispatcher can -// route on the manifest's bare backend id. -package s3compat diff --git a/packages/cli/internal/adapters/deploy/s3compat/ops.go b/packages/cli/internal/adapters/deploy/s3compat/ops.go deleted file mode 100644 index 641e3db9..00000000 --- a/packages/cli/internal/adapters/deploy/s3compat/ops.go +++ /dev/null @@ -1,478 +0,0 @@ -package s3compat - -// ops.go exposes the Render / Apply operations as ordinary package -// functions with package-local types. provider.go adapts these to -// deploy.Provider for each of the six S3-compatible backend ids. - -import ( - "context" - stderrors "errors" - "fmt" - "mime" - "os" - "path/filepath" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - awsConfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/aws/smithy-go" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" -) - -// defaultBuildOutput is the conventional Vite/CRA/Astro output dir. -// Profile / per-subproject extensions can override later if needed. -const defaultBuildOutput = "dist" - -// Endpoint is the s3-relevant slice of a deploy profile. Single shape -// covers AWS S3 / Aliyun OSS / Tencent COS / MinIO / RustFS / -// Cloudflare R2 — the Endpoint URL + region discriminate the vendor; -// the active backend id is owned by the manifest, not this struct. -type Endpoint struct { - Endpoint string - Region string - Bucket string - ForcePathStyle bool -} - -// Credentials is the AccessKey pair (same shape across all S3-protocol -// vendors). -type Credentials struct { - AccessKeyID string - AccessKeySecret string -} - -// Subproject is the per-subproject context Render / Apply need. -type Subproject struct { - Name string - RelativeDir string - Toolchain string -} - -// Stable JSON envelope schema string for the s3 apply operation. -const SchemaApply = "one-cli/deploy-apply/v1" - -// ApplyInput addresses Apply. -type ApplyInput struct { - ProjectRoot string - Subproject *Subproject - Endpoint *Endpoint - Credentials *Credentials - DryRun bool - // Kind is the bare backend id ("aliyun-oss" / "aws-s3" / ...). Used - // only in error messages so the user sees the same id they typed - // at `one configure add deploy/`. - Kind string -} - -// ApplyResult is the Apply envelope. -type ApplyResult struct { - Schema string `json:"schema"` - Argv []string `json:"argv"` - DryRun bool `json:"dry_run"` -} - -type bucketClient interface { - HeadBucket(context.Context, *s3.HeadBucketInput, ...func(*s3.Options)) (*s3.HeadBucketOutput, error) - CreateBucket(context.Context, *s3.CreateBucketInput, ...func(*s3.Options)) (*s3.CreateBucketOutput, error) -} - -// Apply builds and uploads the configured subproject's static output. -func Apply(ctx context.Context, in ApplyInput) (*ApplyResult, error) { - kindLabel := in.Kind - if kindLabel == "" { - kindLabel = "aws-s3" - } - if in.Subproject == nil { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("deploy/%s Apply 需要 Subproject 上下文(per-subproject scope)", kindLabel)) - } - if in.Endpoint == nil { - return nil, cliErrors.New(cliErrors.PROFILE_NONE_CONFIGURED, - fmt.Sprintf("deploy/%s 缺少 endpoint 配置;先 `one configure add deploy/%s ...`", kindLabel, kindLabel)) - } - if in.Credentials == nil || - strings.TrimSpace(in.Credentials.AccessKeyID) == "" || - strings.TrimSpace(in.Credentials.AccessKeySecret) == "" { - return nil, cliErrors.New(cliErrors.PROFILE_NONE_CONFIGURED, - fmt.Sprintf("deploy/%s 缺少 credentials;profile 没有 accessKeyId/accessKeySecret", kindLabel)) - } - subDir := filepath.Join(in.ProjectRoot, filepath.FromSlash(in.Subproject.RelativeDir)) - outDir := filepath.Join(subDir, defaultBuildOutput) - bucket := in.Endpoint.Bucket - - if in.DryRun { - argv := []string{ - "s3-upload", - "--endpoint", endpointDisplay(in.Endpoint), - "--bucket", bucket, - "--ensure-bucket", - "--source", outDir, - "--build-cmd", "pnpm run build", - } - return &ApplyResult{Schema: SchemaApply, Argv: argv, DryRun: true}, nil - } - - if err := runBuild(ctx, subDir, in.Subproject.Toolchain); err != nil { - return nil, err - } - files, err := walkUploadable(outDir) - if err != nil { - return nil, err - } - argv := []string{ - "s3-upload", - "--endpoint", endpointDisplay(in.Endpoint), - "--bucket", bucket, - "--source", outDir, - fmt.Sprintf("--files=%d", len(files)), - } - - client, err := newS3Client(ctx, in.Endpoint, in.Credentials) - if err != nil { - return nil, err - } - if err := ensureBucket(ctx, client, in.Endpoint, bucket); err != nil { - return nil, err - } - for _, f := range files { - if err := uploadOne(ctx, client, bucket, f); err != nil { - return nil, uploadError(err, in.Endpoint, f.relPath) - } - } - return &ApplyResult{Schema: SchemaApply, Argv: argv, DryRun: false}, nil -} - -func ensureBucket(ctx context.Context, client bucketClient, ep *Endpoint, bucket string) error { - bucket = strings.TrimSpace(bucket) - if bucket == "" { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - "S3-compatible deploy 缺少 bucket;默认应来自 one.manifest.json#project.id。") - } - headInput := &s3.HeadBucketInput{Bucket: &bucket} - if _, err := client.HeadBucket(ctx, headInput); err == nil { - return nil - } else if !isBucketMissing(err) { - return bucketCheckError(err, ep, bucket) - } - - if _, err := client.CreateBucket(ctx, createBucketInput(ep, bucket)); err != nil { - if isBucketAlreadyOwned(err) { - return nil - } - if isBucketNameTaken(err) { - return bucketNameTakenError(err, ep, bucket) - } - return bucketCreateError(err, ep, bucket) - } - return nil -} - -func createBucketInput(ep *Endpoint, bucket string) *s3.CreateBucketInput { - in := &s3.CreateBucketInput{Bucket: &bucket} - if ep == nil || strings.TrimSpace(ep.Endpoint) != "" { - return in - } - region := strings.TrimSpace(ep.Region) - if region == "" || region == "us-east-1" { - return in - } - in.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{ - LocationConstraint: s3types.BucketLocationConstraint(region), - } - return in -} - -func bucketCheckError(err error, ep *Endpoint, bucket string) error { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("无法检查 S3 bucket %q:%v", bucket, err)). - WithContext(map[string]any{ - "bucket": bucket, - "endpoint": endpointDisplay(ep), - }). - WithRemediation(output.Remediation{ - Action: "check-s3-permission", - Hint: "确认 S3-compatible profile 的 AK/SK 有 HeadBucket 权限,且 endpoint/region 配置正确", - }) -} - -func bucketCreateError(err error, ep *Endpoint, bucket string) error { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("S3 bucket %q 不存在,自动创建失败:%v", bucket, err)). - WithContext(map[string]any{ - "bucket": bucket, - "endpoint": endpointDisplay(ep), - }). - WithRemediation( - output.Remediation{ - Action: "check-s3-permission", - Hint: "确认 S3-compatible profile 的 AK/SK 有 CreateBucket 权限", - }, - output.Remediation{ - Action: "use-existing-bucket", - Hint: "如果对象存储已经有别的 bucket,请在 one.manifest.json 的 projects[].deploy.bucket 写入那个 bucket 名", - }, - ) -} - -func bucketNameTakenError(err error, ep *Endpoint, bucket string) error { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("S3 bucket %q 已存在但不属于当前账号,无法自动创建:%v", bucket, err)). - WithContext(map[string]any{ - "bucket": bucket, - "endpoint": endpointDisplay(ep), - }). - WithRemediation( - output.Remediation{ - Action: "change-s3-bucket", - Hint: "换一个当前账号可创建/拥有的 bucket 名", - }, - output.Remediation{ - Action: "use-owned-bucket", - Hint: "如果已经有可用 bucket,请在 one.manifest.json 的 projects[].deploy.bucket 写入那个 bucket 名", - }, - ) -} - -func uploadError(err error, ep *Endpoint, key string) error { - if isNoSuchBucket(err) { - bucket := "" - if ep != nil { - bucket = strings.TrimSpace(ep.Bucket) - } - endpoint := endpointDisplay(ep) - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("S3 bucket %q 不存在,无法上传 %s。当前 bucket 默认来自 one.manifest.json#project.id;请先在该 S3 endpoint 创建 bucket,或把 projects[].deploy.bucket 改成已存在的 bucket。", bucket, key)). - WithContext(map[string]any{ - "bucket": bucket, - "endpoint": endpoint, - "key": key, - }). - WithRemediation(s3BucketRemediations(ep, bucket)...) - } - return cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("S3 上传失败 %s:%v", key, err)). - WithContext(map[string]any{ - "bucket": bucketFromEndpoint(ep), - "endpoint": endpointDisplay(ep), - "key": key, - }) -} - -func isNoSuchBucket(err error) bool { - return isBucketMissing(err) -} - -func isBucketMissing(err error) bool { - var apiErr smithy.APIError - if stderrors.As(err, &apiErr) { - switch apiErr.ErrorCode() { - case "NoSuchBucket", "NotFound": - return true - } - } - msg := err.Error() - return strings.Contains(msg, "NoSuchBucket") || - strings.Contains(msg, "Volume not found") || - strings.Contains(msg, "StatusCode: 404") -} - -func isBucketAlreadyOwned(err error) bool { - var apiErr smithy.APIError - if stderrors.As(err, &apiErr) { - switch apiErr.ErrorCode() { - case "BucketAlreadyOwnedByYou": - return true - } - } - msg := err.Error() - return strings.Contains(msg, "BucketAlreadyOwnedByYou") -} - -func isBucketNameTaken(err error) bool { - var apiErr smithy.APIError - if stderrors.As(err, &apiErr) { - return apiErr.ErrorCode() == "BucketAlreadyExists" - } - return strings.Contains(err.Error(), "BucketAlreadyExists") -} - -func s3BucketRemediations(ep *Endpoint, bucket string) []output.Remediation { - steps := []output.Remediation{ - { - Action: "create-s3-bucket", - Hint: fmt.Sprintf("在当前 S3/RustFS/MinIO endpoint 创建 bucket/volume:%s", bucket), - }, - { - Action: "use-existing-bucket", - Hint: "如果对象存储已经有别的 bucket,请在 one.manifest.json 的 projects[].deploy.bucket 写入那个 bucket 名", - }, - } - if ep != nil && strings.TrimSpace(ep.Endpoint) != "" && bucket != "" { - cmd := fmt.Sprintf("aws s3api create-bucket --bucket %s --endpoint-url %s", bucket, strings.TrimSpace(ep.Endpoint)) - if region := strings.TrimSpace(ep.Region); region != "" { - cmd += " --region " + region - } - steps[0].Command = cmd - } - return steps -} - -func buildCommand(toolchain string) ([]string, error) { - if toolchain != "" && toolchain != "node" { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("S3-compatible deploy 当前只支持 node toolchain;该项目 toolchain=%s。请改用 deploy/kustomize 或贡献新 toolchain 支持。", toolchain)) - } - return []string{"pnpm", "run", "build"}, nil -} - -func runBuild(ctx context.Context, subDir, toolchain string) error { - argv, err := buildCommand(toolchain) - if err != nil { - return err - } - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Dir = subDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return cliErrors.New(cliErrors.RUN_COMMAND_NOT_FOUND, - fmt.Sprintf("`pnpm run build` 在 %s 失败:%v", subDir, err)) - } - return nil -} - -type uploadFile struct { - relPath string - absPath string -} - -func walkUploadable(outDir string) ([]uploadFile, error) { - info, err := os.Stat(outDir) - if err != nil { - if os.IsNotExist(err) { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("构建产物不存在:%s(构建步骤是不是没跑?)", outDir)) - } - return nil, err - } - if !info.IsDir() { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("构建产物应该是目录,但 %s 是文件", outDir)) - } - var out []uploadFile - err = filepath.Walk(outDir, func(path string, fi os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if fi.IsDir() { - return nil - } - rel, relErr := filepath.Rel(outDir, path) - if relErr != nil { - return relErr - } - out = append(out, uploadFile{ - relPath: filepath.ToSlash(rel), - absPath: path, - }) - return nil - }) - if err != nil { - return nil, err - } - return out, nil -} - -func newS3Client(ctx context.Context, ep *Endpoint, creds *Credentials) (*s3.Client, error) { - staticCreds := credentials.NewStaticCredentialsProvider( - creds.AccessKeyID, creds.AccessKeySecret, "") - region := strings.TrimSpace(ep.Region) - if region == "" { - region = "us-east-1" - } - cfg, err := awsConfig.LoadDefaultConfig(ctx, - awsConfig.WithRegion(region), - awsConfig.WithCredentialsProvider(staticCreds), - ) - if err != nil { - return nil, err - } - opts := []func(*s3.Options){ - func(o *s3.Options) { - if strings.TrimSpace(ep.Endpoint) != "" { - o.BaseEndpoint = &ep.Endpoint - } - // S3-compatible providers such as Aliyun OSS reject the SDK's - // default trailer-checksum aws-chunked upload mode. Compute - // request checksums only when an operation explicitly requires it. - o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired - if ep.ForcePathStyle { - o.UsePathStyle = true - } - }, - } - return s3.NewFromConfig(cfg, opts...), nil -} - -type objectClient interface { - PutObject(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error) -} - -func uploadOne(ctx context.Context, client objectClient, bucket string, f uploadFile) error { - body, err := os.Open(f.absPath) - if err != nil { - return err - } - defer body.Close() - info, err := body.Stat() - if err != nil { - return err - } - contentType := contentTypeForPath(f.relPath) - if contentType == "" { - contentType = "application/octet-stream" - } - in := &s3.PutObjectInput{ - Bucket: &bucket, - Key: &f.relPath, - Body: body, - ContentLength: aws.Int64(info.Size()), - ContentType: &contentType, - } - _, err = client.PutObject(ctx, in) - return err -} - -func contentTypeForPath(path string) string { - switch strings.ToLower(filepath.Ext(path)) { - case ".js", ".mjs": - return "text/javascript; charset=utf-8" - case ".css": - return "text/css; charset=utf-8" - case ".html", ".htm": - return "text/html; charset=utf-8" - case ".json": - return "application/json" - } - return mime.TypeByExtension(filepath.Ext(path)) -} - -func endpointDisplay(ep *Endpoint) string { - if ep == nil || strings.TrimSpace(ep.Endpoint) == "" { - return "AWS S3 (default)" - } - return ep.Endpoint -} - -func bucketFromEndpoint(ep *Endpoint) string { - if ep == nil { - return "(no bucket)" - } - return ep.Bucket -} diff --git a/packages/cli/internal/adapters/deploy/s3compat/ops_test.go b/packages/cli/internal/adapters/deploy/s3compat/ops_test.go deleted file mode 100644 index 0d1f60b2..00000000 --- a/packages/cli/internal/adapters/deploy/s3compat/ops_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package s3compat - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/smithy-go" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -type fakeBucketClient struct { - headErr error - createErr error - headCalls int - createCalls int - createBucket string - createConfig bool -} - -func (f *fakeBucketClient) HeadBucket(context.Context, *s3.HeadBucketInput, ...func(*s3.Options)) (*s3.HeadBucketOutput, error) { - f.headCalls++ - if f.headErr != nil { - return nil, f.headErr - } - return &s3.HeadBucketOutput{}, nil -} - -func (f *fakeBucketClient) CreateBucket(_ context.Context, in *s3.CreateBucketInput, _ ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { - f.createCalls++ - if in != nil && in.Bucket != nil { - f.createBucket = *in.Bucket - } - f.createConfig = in != nil && in.CreateBucketConfiguration != nil - if f.createErr != nil { - return nil, f.createErr - } - return &s3.CreateBucketOutput{}, nil -} - -type fakeObjectClient struct { - input *s3.PutObjectInput -} - -func (f *fakeObjectClient) PutObject(_ context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - f.input = in - return &s3.PutObjectOutput{}, nil -} - -func TestEnsureBucketSkipsCreateWhenBucketExists(t *testing.T) { - client := &fakeBucketClient{} - if err := ensureBucket(context.Background(), client, &Endpoint{Bucket: "demo-abc123"}, "demo-abc123"); err != nil { - t.Fatalf("ensureBucket: %v", err) - } - if client.headCalls != 1 || client.createCalls != 0 { - t.Fatalf("calls: head=%d create=%d, want head=1 create=0", client.headCalls, client.createCalls) - } -} - -func TestEnsureBucketCreatesWhenMissing(t *testing.T) { - client := &fakeBucketClient{ - headErr: &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "Volume not found"}, - } - err := ensureBucket(context.Background(), client, &Endpoint{ - Endpoint: "http://127.0.0.1:9000", - Region: "us-east-1", - Bucket: "demo-abc123", - }, "demo-abc123") - if err != nil { - t.Fatalf("ensureBucket: %v", err) - } - if client.headCalls != 1 || client.createCalls != 1 { - t.Fatalf("calls: head=%d create=%d, want head=1 create=1", client.headCalls, client.createCalls) - } - if client.createBucket != "demo-abc123" { - t.Fatalf("created bucket = %q, want demo-abc123", client.createBucket) - } - if client.createConfig { - t.Fatal("custom endpoint create should not include AWS LocationConstraint") - } -} - -func TestEnsureBucketTreatsAlreadyOwnedAsSuccess(t *testing.T) { - client := &fakeBucketClient{ - headErr: &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "not found"}, - createErr: &smithy.GenericAPIError{Code: "BucketAlreadyOwnedByYou", Message: "already owned"}, - } - err := ensureBucket(context.Background(), client, &Endpoint{Bucket: "demo-abc123"}, "demo-abc123") - if err != nil { - t.Fatalf("ensureBucket: %v", err) - } - if client.headCalls != 1 || client.createCalls != 1 { - t.Fatalf("calls: head=%d create=%d, want head=1 create=1", client.headCalls, client.createCalls) - } -} - -func TestCreateBucketInputSetsAWSRegionConstraint(t *testing.T) { - in := createBucketInput(&Endpoint{Region: "ap-southeast-1"}, "demo-abc123") - if in.CreateBucketConfiguration == nil { - t.Fatal("AWS non-us-east-1 create should include LocationConstraint") - } - if got := string(in.CreateBucketConfiguration.LocationConstraint); got != "ap-southeast-1" { - t.Fatalf("LocationConstraint = %q, want ap-southeast-1", got) - } - - in = createBucketInput(&Endpoint{Endpoint: "http://127.0.0.1:9000", Region: "ap-southeast-1"}, "demo-abc123") - if in.CreateBucketConfiguration != nil { - t.Fatal("custom S3-compatible endpoint should not include AWS LocationConstraint") - } -} - -func TestEnsureBucketBucketAlreadyExistsIsActionable(t *testing.T) { - client := &fakeBucketClient{ - headErr: &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "not found"}, - createErr: &smithy.GenericAPIError{Code: "BucketAlreadyExists", Message: "bucket exists"}, - } - err := ensureBucket(context.Background(), client, &Endpoint{ - Endpoint: "https://s3.amazonaws.com", - Bucket: "demo-abc123", - }, "demo-abc123") - - got, ok := err.(*output.Error) - if !ok { - t.Fatalf("error type = %T, want *output.Error", err) - } - if !strings.Contains(got.Message, `S3 bucket "demo-abc123" 已存在但不属于当前账号`) { - t.Fatalf("message should explain bucket name collision, got %q", got.Message) - } - if got.Context["bucket"] != "demo-abc123" { - t.Fatalf("context.bucket = %v, want demo-abc123", got.Context["bucket"]) - } - if len(got.Remediation) != 2 { - t.Fatalf("remediation count = %d, want 2: %#v", len(got.Remediation), got.Remediation) - } - if got.Remediation[0].Action != "change-s3-bucket" || got.Remediation[1].Action != "use-owned-bucket" { - t.Fatalf("unexpected remediation: %#v", got.Remediation) - } -} - -func TestEnsureBucketCreateFailureIsActionable(t *testing.T) { - client := &fakeBucketClient{ - headErr: &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "Volume not found"}, - createErr: &smithy.GenericAPIError{Code: "AccessDenied", Message: "denied"}, - } - err := ensureBucket(context.Background(), client, &Endpoint{ - Endpoint: "http://127.0.0.1:9000", - Bucket: "demo-abc123", - }, "demo-abc123") - - got, ok := err.(*output.Error) - if !ok { - t.Fatalf("error type = %T, want *output.Error", err) - } - if !strings.Contains(got.Message, `S3 bucket "demo-abc123" 不存在,自动创建失败`) { - t.Fatalf("message should explain create failure, got %q", got.Message) - } - if len(got.Remediation) != 2 { - t.Fatalf("remediation count = %d, want 2: %#v", len(got.Remediation), got.Remediation) - } -} - -func TestUploadErrorNoSuchBucketIsActionable(t *testing.T) { - err := uploadError(&smithy.GenericAPIError{ - Code: "NoSuchBucket", - Message: "Volume not found", - }, &Endpoint{ - Endpoint: "http://127.0.0.1:9000", - Region: "us-east-1", - Bucket: "demo-abc123", - }, "404.html") - - got, ok := err.(*output.Error) - if !ok { - t.Fatalf("error type = %T, want *output.Error", err) - } - if !strings.Contains(got.Message, `S3 bucket "demo-abc123" 不存在`) { - t.Fatalf("message should name the missing bucket, got %q", got.Message) - } - if got.Context["bucket"] != "demo-abc123" { - t.Fatalf("context.bucket = %v, want demo-abc123", got.Context["bucket"]) - } - if len(got.Remediation) != 2 { - t.Fatalf("remediation count = %d, want 2: %#v", len(got.Remediation), got.Remediation) - } - if got.Remediation[0].Action != "create-s3-bucket" { - t.Fatalf("first remediation = %#v", got.Remediation[0]) - } - if !strings.Contains(got.Remediation[0].Command, "--bucket demo-abc123") || - !strings.Contains(got.Remediation[0].Command, "--endpoint-url http://127.0.0.1:9000") { - t.Fatalf("create command missing bucket or endpoint: %q", got.Remediation[0].Command) - } -} - -func TestUploadOneSetsContentLengthAndLeavesChecksumUnset(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "app.js") - payload := []byte("console.log('ok')\n") - if err := os.WriteFile(path, payload, 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - - client := &fakeObjectClient{} - err := uploadOne(context.Background(), client, "demo-abc123", uploadFile{ - relPath: "assets/app.js", - absPath: path, - }) - if err != nil { - t.Fatalf("uploadOne: %v", err) - } - if client.input == nil { - t.Fatal("PutObject was not called") - } - if client.input.ContentLength == nil || *client.input.ContentLength != int64(len(payload)) { - t.Fatalf("ContentLength = %v, want %d", client.input.ContentLength, len(payload)) - } - if string(client.input.ChecksumAlgorithm) != "" { - t.Fatalf("ChecksumAlgorithm = %q, want unset", client.input.ChecksumAlgorithm) - } - if client.input.ContentType == nil || *client.input.ContentType != "text/javascript; charset=utf-8" { - t.Fatalf("ContentType = %v, want JavaScript MIME", client.input.ContentType) - } -} diff --git a/packages/cli/internal/adapters/deploy/s3compat/provider.go b/packages/cli/internal/adapters/deploy/s3compat/provider.go deleted file mode 100644 index 1dffabdd..00000000 --- a/packages/cli/internal/adapters/deploy/s3compat/provider.go +++ /dev/null @@ -1,94 +0,0 @@ -package s3compat - -// provider.go adapts s3compat to the deploy.Provider interface. One -// parameterised providerImpl per backend id; all six share the same -// Apply implementation because the underlying upload protocol is -// identical — only the user-visible id, prompts, and defaults differ -// (those live in configurecmd, not here). - -import ( - "context" - "fmt" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -type providerImpl struct { - kind string -} - -func (p providerImpl) ID() string { return p.kind } - -func (p providerImpl) Apply(ctx context.Context, in deploy.ApplyInput) (*deploy.ApplyResult, error) { - if in.Resolved == nil || in.Resolved.Profile.S3 == nil { - return nil, cliErrors.New(cliErrors.PROFILE_NONE_CONFIGURED, - fmt.Sprintf("deploy/%s 缺少 profile;先 `one configure add deploy/%s --use`。", p.kind, p.kind)) - } - ep, creds := endpointAndCredsFromProfile(in.Resolved, in.Manifest, in.Project.Name) - res, err := Apply(ctx, ApplyInput{ - ProjectRoot: in.ProjectRoot, - Subproject: &Subproject{ - Name: in.Project.Name, - RelativeDir: in.Project.RelativeDir, - Toolchain: in.Toolchain, - }, - Endpoint: ep, - Credentials: creds, - DryRun: in.DryRun, - Kind: p.kind, - }) - if err != nil { - return nil, err - } - if res == nil { - return nil, nil - } - return &deploy.ApplyResult{ - Schema: res.Schema, - Argv: res.Argv, - DryRun: res.DryRun, - }, nil -} - -func Providers() []deploy.Provider { - providers := make([]deploy.Provider, 0, 6) - for _, kind := range []string{ - workspace.DeployBackendAliyunOSS, - workspace.DeployBackendTencentCOS, - workspace.DeployBackendAWSS3, - workspace.DeployBackendMinIO, - workspace.DeployBackendRustFS, - workspace.DeployBackendR2, - } { - providers = append(providers, providerImpl{kind: kind}) - } - return providers -} - -// endpointAndCredsFromProfile pulls the s3 endpoint + credentials out -// of the resolved profile, and threads in the per-project bucket from -// the manifest. The profile holds endpoint + credentials (machine- -// level); the manifest holds the per-project bucket override. -func endpointAndCredsFromProfile(resolved *profile.Resolved, m *workspace.Manifest, projectName string) (*Endpoint, *Credentials) { - if resolved == nil || resolved.Profile.S3 == nil { - return nil, nil - } - src := resolved.Profile.S3 - ep := &Endpoint{ - Endpoint: src.Endpoint, - Region: src.Region, - Bucket: workspace.DeployBucketForProject(m, projectName), - ForcePathStyle: src.ForcePathStyle, - } - var creds *Credentials - if src.Credentials != nil { - creds = &Credentials{ - AccessKeyID: src.Credentials.AccessKeyID, - AccessKeySecret: src.Credentials.AccessKeySecret, - } - } - return ep, creds -} diff --git a/packages/cli/internal/adapters/deploy/vercel/config.go b/packages/cli/internal/adapters/deploy/vercel/config.go deleted file mode 100644 index 78ccb21f..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/config.go +++ /dev/null @@ -1,70 +0,0 @@ -package vercel - -import ( - "encoding/json" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// ProjectConfig is the typed view over -// `projects[i].domains.deploy.config` when the per-project deploy backend -// is Vercel. ProjectID is the `prj_xxx` id Vercel assigns; ProjectName is -// its display slug. Env names the deploy target environment (drawn from -// manifest.environments.names; empty / "prod" → production deploy, anything -// else → preview). -type ProjectConfig struct { - ProjectID string `json:"projectId,omitempty"` - ProjectName string `json:"projectName,omitempty"` - Env string `json:"env,omitempty"` -} - -// DecodeProjectConfig pulls the Vercel-specific config blob out of the -// manifest's per-project deploy section. Returns (nil, nil) when no -// project with that name has a Vercel deploy section configured. -func DecodeProjectConfig(m *workspace.Manifest, projectName string) (*ProjectConfig, error) { - if m == nil { - return nil, nil - } - for _, p := range m.Projects { - if p.Name != projectName { - continue - } - if p.Domains == nil || p.Domains.Deploy == nil || p.Domains.Deploy.Kind != workspace.DeployBackendVercel { - return nil, nil - } - if len(p.Domains.Deploy.Config) == 0 { - return &ProjectConfig{}, nil - } - var cfg ProjectConfig - if err := json.Unmarshal(p.Domains.Deploy.Config, &cfg); err != nil { - return nil, err - } - return &cfg, nil - } - return nil, nil -} - -// EncodeProjectConfig writes cfg back into projects[i].domains.deploy.config -// (in memory), creating the deploy section if necessary. Caller persists -// via WriteManifest. -func EncodeProjectConfig(p *workspace.ManifestProject, cfg *ProjectConfig) error { - if p == nil { - return nil - } - if p.Domains == nil { - p.Domains = &workspace.ProjectDomains{} - } - if p.Domains.Deploy == nil { - p.Domains.Deploy = &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendVercel} - } - if cfg == nil { - p.Domains.Deploy.Config = nil - return nil - } - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - p.Domains.Deploy.Config = raw - return nil -} diff --git a/packages/cli/internal/adapters/deploy/vercel/ops.go b/packages/cli/internal/adapters/deploy/vercel/ops.go deleted file mode 100644 index d8b16c59..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/ops.go +++ /dev/null @@ -1,294 +0,0 @@ -// Package vercel implements the deploy/vercel backend. The provider -// shells out to the upstream `vercel` CLI rather than calling Vercel's -// REST API directly: the CLI handles project linking, build artifact -// upload, environment-variable sync, and deployment polling for us, so -// adding a Go-side reimplementation would be net-negative effort. -// -// Layout follows the same shape as other adapters/deploy/ packages — -// ops.go houses argv builders + the actual exec call so it stays -// trivially testable, sync.go scaffolds vercel.json, and provider.go -// adapts everything onto the deploy.Provider interface. Bootstrap registers -// the provider explicitly. -package vercel - -import ( - "context" - "fmt" - "os" - "os/exec" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "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/ports/secrets" -) - -// SchemaApply is the JSON envelope schema string for a vercel deploy. -// Versioned per-backend to allow Vercel-specific shape changes without -// disturbing kustomize / s3 envelopes. -const SchemaApply = "one-cli/deploy-apply-vercel/v1" - -// CLIBinary is the executable name we look for on $PATH. Exported so -// tests can stub it (the test variant points at a fake binary). -var CLIBinary = "vercel" - -// ApplyInput addresses Apply. -type ApplyInput struct { - // ProjectDir is the absolute working directory `vercel` runs in - // (the per-project dir, not the workspace root). The `vercel` - // CLI scans this dir for vercel.json + framework auto-detection. - ProjectDir string - - // APIToken is the Vercel API token (from the deploy/vercel profile). - APIToken string - - // Team is the org slug passed via `--scope`. Empty = personal scope. - Team string - - // Env names the deploy target environment (from manifest.environments.names, - // or the deploy command's --env flag). Empty or "prod" runs a production - // deploy (vercel build/deploy --prod, vercel pull --environment=production); - // any other value runs a preview deploy. Vercel only has the two-state - // production/preview distinction at the platform level — staging or - // custom envs all collapse to preview here. - Env string - - // DryRun returns the planned argv without invoking `vercel`. - DryRun bool - - // InjectedEnv is the project's user-set env vars (resolved by - // deploycmd from dotenv / Infisical). Threaded into the vercel CLI - // subprocess via cmd.Env so user-side build scripts (Next.js, - // Nuxt, etc.) read them via process.env during `vercel build`. - // - // We deliberately do NOT write these into .vercel/.env.* files — - // those files are owned by `vercel pull` (mirror of Vercel cloud - // env). Mixing local one-cli env with cloud-pulled env on disk - // would create two competing sources of truth. Runtime env (i.e. - // what the deployed function sees in production) is still - // Vercel's job, configured via Vercel UI / `vercel env add` and - // pulled by `vercel pull`. nil = no injection. - InjectedEnv map[string]string -} - -// ApplyResult is the JSON envelope emitted on success. -type ApplyResult struct { - Schema string `json:"schema"` - Argv []string `json:"argv"` - CommandLines []string `json:"command_lines,omitempty"` - DryRun bool `json:"dry_run"` - - // DeploymentURL is captured from the vercel CLI output on success. - // Empty in dry-run. - DeploymentURL string `json:"deployment_url,omitempty"` -} - -// Apply runs the vercel CLI sequence to deploy one project: -// -// vercel pull --yes --environment=production [--scope=] --token=... -// vercel build [--prod] -// vercel deploy --prebuilt [--prod] [--scope=] --token=... -// -// The pull step caches project link + env vars; build does the -// platform's prebuild step locally; deploy uploads the prebuilt -// artifacts. Token is passed via --token flag (vercel CLI supports -// reading from VERCEL_TOKEN as well, but flag-passing keeps the -// command transcript explicit for dry-run output). -func Apply(ctx context.Context, in ApplyInput) (*ApplyResult, error) { - if strings.TrimSpace(in.APIToken) == "" { - return nil, cliErrors.New(cliErrors.VERCEL_PROFILE_INVALID, - "deploy/vercel profile 缺少 API token。先 `one configure add deploy/vercel --profile --token --use`。") - } - - pullArgv := buildPullArgv(in) - buildArgv := buildBuildArgv(in) - deployArgv := buildDeployArgv(in) - commandLines := []string{ - argvDisplay(maskTokenInArgv(pullArgv)), - argvDisplay(maskTokenInArgv(buildArgv)), - argvDisplay(maskTokenInArgv(deployArgv)), - } - - if in.DryRun { - return &ApplyResult{ - Schema: SchemaApply, - Argv: maskTokenInArgv(deployArgv), - CommandLines: commandLines, - DryRun: true, - }, nil - } - - if _, err := exec.LookPath(CLIBinary); err != nil { - return nil, cliErrors.New(cliErrors.VERCEL_CLI_MISSING, - "未在 PATH 中找到 `vercel` 二进制。安装方式见错误的 remediation。"). - WithContext(map[string]any{"binary": CLIBinary}) - } - - if err := runStep(ctx, in.ProjectDir, pullArgv, "vercel pull", in.InjectedEnv); err != nil { - return nil, err - } - if err := runStep(ctx, in.ProjectDir, buildArgv, "vercel build", in.InjectedEnv); err != nil { - return nil, err - } - url, err := runDeployStep(ctx, in.ProjectDir, deployArgv, in.InjectedEnv) - if err != nil { - return nil, err - } - return &ApplyResult{ - Schema: SchemaApply, - Argv: maskTokenInArgv(deployArgv), - CommandLines: commandLines, - DryRun: false, - DeploymentURL: url, - }, nil -} - -// isProduction reports whether the requested env represents Vercel's -// production tier. Empty (default) and "prod" both map to production; -// any other value (e.g. "dev", "staging", custom) maps to preview. -func isProduction(env string) bool { - env = strings.TrimSpace(env) - return env == "" || env == "prod" -} - -func buildPullArgv(in ApplyInput) []string { - argv := []string{CLIBinary, "pull", "--yes"} - if isProduction(in.Env) { - argv = append(argv, "--environment=production") - } else { - argv = append(argv, "--environment=preview") - } - if scope := strings.TrimSpace(in.Team); scope != "" { - argv = append(argv, "--scope="+scope) - } - argv = append(argv, "--token="+in.APIToken) - return argv -} - -func buildBuildArgv(in ApplyInput) []string { - argv := []string{CLIBinary, "build"} - if isProduction(in.Env) { - argv = append(argv, "--prod") - } - if scope := strings.TrimSpace(in.Team); scope != "" { - argv = append(argv, "--scope="+scope) - } - argv = append(argv, "--token="+in.APIToken) - return argv -} - -func buildDeployArgv(in ApplyInput) []string { - argv := []string{CLIBinary, "deploy", "--prebuilt"} - if isProduction(in.Env) { - argv = append(argv, "--prod") - } - if scope := strings.TrimSpace(in.Team); scope != "" { - argv = append(argv, "--scope="+scope) - } - argv = append(argv, "--token="+in.APIToken) - return argv -} - -// maskTokenInArgv replaces the literal token value with `********` so -// the wire-format envelope (and dry-run output) never leaks the API -// token. The `vercel` invocation itself uses the real argv. -func maskTokenInArgv(argv []string) []string { - out := make([]string, len(argv)) - for i, a := range argv { - switch { - case strings.HasPrefix(a, "--token="): - out[i] = "--token=********" - default: - out[i] = a - } - } - return out -} - -// argvDisplay joins argv into a copy-pasteable single-line string for -// dry-run / command_lines output. -func argvDisplay(argv []string) string { - return strings.Join(argv, " ") -} - -func runStep(ctx context.Context, dir string, argv []string, label string, injected map[string]string) error { - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = vercelEnv(os.Environ(), injected) - if err := cmd.Run(); err != nil { - return cliErrors.New(cliErrors.VERCEL_DEPLOY_FAILED, - fmt.Sprintf("`%s` 失败:%v", label, err)). - WithContext(map[string]any{"argv": maskTokenInArgv(argv)}). - WithRemediation(output.Remediation{ - Action: "rerun-step", - Hint: "在项目目录手动跑一次该步骤,看完整 vercel CLI 输出", - }) - } - return nil -} - -// runDeployStep is the same as runStep except it also captures stdout -// to lift the deployment URL out of the CLI output. `vercel deploy` -// prints the URL on its own line, e.g. https://my-app-abc123.vercel.app. -func runDeployStep(ctx context.Context, dir string, argv []string, injected map[string]string) (string, error) { - cmd := platformprocess.CommandContext(ctx, argv[0], argv[1:]...) - cmd.Dir = dir - cmd.Stderr = os.Stderr - cmd.Env = vercelEnv(os.Environ(), injected) - stdout, err := cmd.StdoutPipe() - if err != nil { - return "", err - } - if err := cmd.Start(); err != nil { - return "", err - } - url := captureDeploymentURL(stdout) - if err := cmd.Wait(); err != nil { - return "", cliErrors.New(cliErrors.VERCEL_DEPLOY_FAILED, - fmt.Sprintf("`vercel deploy` 失败:%v", err)). - WithContext(map[string]any{"argv": maskTokenInArgv(argv)}). - WithRemediation(output.Remediation{ - Action: "rerun-step", - Hint: "手动 `vercel deploy --prebuilt --prod` 看完整输出", - }) - } - return url, nil -} - -// vercelEnv merges user-injected env vars on top of the parent shell -// (override=true so user .env beats stale shell vars). Unlike wrangler / -// edgeone we do NOT strip auth env vars: vercel reads its API token -// from the --token argv flag, not from VERCEL_TOKEN env, so there is -// no auth env to protect from accidental injection. nil injected = no -// merge, behaviour equals os.Environ(). -func vercelEnv(parent []string, injected map[string]string) []string { - return secrets.MergeIntoEnviron(parent, injected, true) -} - -// captureDeploymentURL streams the CLI stdout to the user's terminal -// while sniffing for the first https://*.vercel.app URL. Vercel emits -// the production URL on a dedicated line at the end of `vercel deploy`. -func captureDeploymentURL(stdout interface{ Read(p []byte) (int, error) }) string { - buf := make([]byte, 4096) - var collected strings.Builder - for { - n, err := stdout.Read(buf) - if n > 0 { - os.Stdout.Write(buf[:n]) - collected.Write(buf[:n]) - } - if err != nil { - break - } - } - for _, line := range strings.Split(collected.String(), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "https://") && strings.Contains(line, ".vercel.app") { - return line - } - } - return "" -} diff --git a/packages/cli/internal/adapters/deploy/vercel/ops_test.go b/packages/cli/internal/adapters/deploy/vercel/ops_test.go deleted file mode 100644 index 7f887529..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/ops_test.go +++ /dev/null @@ -1,382 +0,0 @@ -package vercel - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -// argv builders ---------------------------------------------------------- - -func TestBuildPullArgvProduction(t *testing.T) { - got := buildPullArgv(ApplyInput{ - APIToken: "tok-123", - Team: "acme", - Env: "prod", - }) - want := []string{ - CLIBinary, "pull", "--yes", - "--environment=production", - "--scope=acme", - "--token=tok-123", - } - assertArgv(t, got, want) -} - -func TestBuildPullArgvPreviewNoTeam(t *testing.T) { - got := buildPullArgv(ApplyInput{ - APIToken: "tok-456", - Env: "dev", - }) - want := []string{ - CLIBinary, "pull", "--yes", - "--environment=preview", - "--token=tok-456", - } - assertArgv(t, got, want) -} - -// Empty Env defaults to the production tier (mirrors the pre-v4 behaviour -// where the unset preview field meant "ship to prod"). -func TestBuildPullArgvEmptyEnvDefaultsToProduction(t *testing.T) { - got := buildPullArgv(ApplyInput{ - APIToken: "tok-default", - }) - want := []string{ - CLIBinary, "pull", "--yes", - "--environment=production", - "--token=tok-default", - } - assertArgv(t, got, want) -} - -func TestBuildBuildArgvProduction(t *testing.T) { - got := buildBuildArgv(ApplyInput{ - APIToken: "tok-789", - Env: "prod", - }) - want := []string{CLIBinary, "build", "--prod", "--token=tok-789"} - assertArgv(t, got, want) -} - -// Staging / dev / any non-prod env collapses to preview on Vercel -// (the platform exposes only two tiers). -func TestBuildBuildArgvStagingCollapsesToPreview(t *testing.T) { - got := buildBuildArgv(ApplyInput{ - APIToken: "tok-stg", - Env: "staging", - }) - want := []string{CLIBinary, "build", "--token=tok-stg"} - assertArgv(t, got, want) -} - -func TestBuildDeployArgvProduction(t *testing.T) { - got := buildDeployArgv(ApplyInput{ - APIToken: "tok-abc", - Team: "myteam", - Env: "prod", - }) - want := []string{ - CLIBinary, "deploy", "--prebuilt", "--prod", - "--scope=myteam", - "--token=tok-abc", - } - assertArgv(t, got, want) -} - -// masking ---------------------------------------------------------------- - -func TestMaskTokenInArgvReplacesTokenValueOnly(t *testing.T) { - in := []string{"vercel", "deploy", "--prebuilt", "--prod", "--scope=acme", "--token=secret-token-xyz"} - got := maskTokenInArgv(in) - for _, a := range got { - if strings.Contains(a, "secret-token-xyz") { - t.Fatalf("argv leaked token: %v", got) - } - } - // Check the masked entry is exactly --token=******** - last := got[len(got)-1] - if last != "--token=********" { - t.Fatalf("last argv = %q, want --token=********", last) - } - // Other entries must be untouched (--scope=acme stays). - if got[len(got)-2] != "--scope=acme" { - t.Fatalf("scope arg got mangled: %q", got[len(got)-2]) - } -} - -func TestMaskTokenInArgvLeavesArgvWithoutTokenAlone(t *testing.T) { - in := []string{"vercel", "build", "--prod"} - got := maskTokenInArgv(in) - assertArgv(t, got, in) -} - -// Apply: dry-run path ---------------------------------------------------- - -func TestApplyDryRunReturnsMaskedArgvAndCommandLines(t *testing.T) { - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "tok-dry", - Team: "team-dry", - Env: "prod", - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("Apply returned nil result") - } - if !res.DryRun { - t.Errorf("DryRun = false, want true") - } - if res.Schema != SchemaApply { - t.Errorf("Schema = %q, want %q", res.Schema, SchemaApply) - } - // argv should not contain plaintext token - for _, a := range res.Argv { - if strings.Contains(a, "tok-dry") { - t.Fatalf("dry-run argv leaked token: %v", res.Argv) - } - } - if len(res.CommandLines) != 3 { - t.Fatalf("CommandLines len = %d, want 3 (pull / build / deploy)", len(res.CommandLines)) - } - for i, line := range res.CommandLines { - if strings.Contains(line, "tok-dry") { - t.Fatalf("CommandLines[%d] leaked token: %q", i, line) - } - } - // Sanity: lines mention the three steps in order. - if !strings.Contains(res.CommandLines[0], "vercel pull --yes") { - t.Errorf("first command line should be `vercel pull --yes ...`, got %q", res.CommandLines[0]) - } - if !strings.Contains(res.CommandLines[1], "vercel build") { - t.Errorf("second command line should be `vercel build ...`, got %q", res.CommandLines[1]) - } - if !strings.Contains(res.CommandLines[2], "vercel deploy --prebuilt") { - t.Errorf("third command line should be `vercel deploy --prebuilt ...`, got %q", res.CommandLines[2]) - } -} - -// Apply: validation paths ------------------------------------------------- - -func TestApplyEmptyTokenSurfacesVercelProfileInvalid(t *testing.T) { - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "", - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for empty APIToken") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "VERCEL_PROFILE_INVALID" { - t.Fatalf("error code = %v, want VERCEL_PROFILE_INVALID", err) - } -} - -// Apply: real exec via fake vercel binary -------------------------------- - -func TestApplyRealExecCapturesDeploymentURL(t *testing.T) { - tmp := t.TempDir() - logPath := filepath.Join(tmp, "vercel.log") - installFakeVercel(t, tmp, logPath, "https://demo-app-abc123.vercel.app") - - res, err := Apply(context.Background(), ApplyInput{ - ProjectDir: tmp, - APIToken: "tok-real", - Team: "acme", - Env: "prod", - DryRun: false, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("nil result") - } - if res.DryRun { - t.Errorf("DryRun = true, want false") - } - if res.DeploymentURL != "https://demo-app-abc123.vercel.app" { - t.Errorf("DeploymentURL = %q, want https://demo-app-abc123.vercel.app", res.DeploymentURL) - } - // log should record three invocations: pull / build / deploy - raw, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read log: %v", err) - } - got := string(raw) - if runtime.GOOS == "windows" { - got = strings.ReplaceAll(got, "\"", "") - } - for _, want := range []string{ - "pull --yes --environment=production --scope=acme --token=tok-real", - "build --prod --scope=acme --token=tok-real", - "deploy --prebuilt --prod --scope=acme --token=tok-real", - } { - if !strings.Contains(got, want) { - t.Errorf("log missing %q\n--- log:\n%s", want, got) - } - } - // envelope argv must mask the token even after a real run - for _, a := range res.Argv { - if strings.Contains(a, "tok-real") { - t.Fatalf("real-run argv leaked token: %v", res.Argv) - } - } -} - -func TestApplyMissingCLISurfacesVercelCLIMissing(t *testing.T) { - // Force LookPath to fail by pointing PATH at an empty dir AND - // renaming the binary we're looking for so the system vercel (if - // any) is invisible. - prevBinary := CLIBinary - t.Cleanup(func() { CLIBinary = prevBinary }) - CLIBinary = "vercel-this-binary-does-not-exist-xyz" - t.Setenv("PATH", t.TempDir()) - - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: t.TempDir(), - APIToken: "tok", - DryRun: false, - }) - if err == nil { - t.Fatal("expected error when vercel CLI missing") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "VERCEL_CLI_MISSING" { - t.Fatalf("error = %v, want VERCEL_CLI_MISSING", err) - } -} - -// helpers ---------------------------------------------------------------- - -func assertArgv(t *testing.T, got, want []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("argv len = %d, want %d\ngot: %v\nwant: %v", len(got), len(want), got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("argv[%d] = %q, want %q\nfull got: %v\nfull want: %v", i, got[i], want[i], got, want) - } - } -} - -// vercelEnv: injected map merges on top of the parent shell (override), -// non-injection paths are no-ops. - -func TestVercelEnvMergesInjectedOverShell(t *testing.T) { - parent := []string{"API_URL=stale", "DATABASE_HOST=ignored-shell"} - injected := map[string]string{"API_URL": "fresh", "FEATURE_FLAGS": "a,b"} - got := vercelEnv(parent, injected) - if !containsString(got, "API_URL=fresh") { - t.Fatalf("injected API_URL should override shell: %v", got) - } - if containsString(got, "API_URL=stale") { - t.Fatalf("stale shell value leaked: %v", got) - } - if !containsString(got, "FEATURE_FLAGS=a,b") { - t.Fatalf("new injected key missing: %v", got) - } - if !containsString(got, "DATABASE_HOST=ignored-shell") { - t.Fatalf("shell-only var dropped: %v", got) - } -} - -func TestVercelEnvNilInjectedNoop(t *testing.T) { - parent := []string{"FOO=bar", "PATH=/usr/bin"} - got := vercelEnv(parent, nil) - if len(got) != len(parent) { - t.Fatalf("nil injection should be a no-op, got %v", got) - } -} - -// .vercel/.env.* invariant: Apply must NOT write any files into -// /.vercel/. Local env injection is process-env-only; -// touching .vercel/.env.* would create a second source of truth that -// fights the cloud-pulled env files. - -func TestApplyNeverWritesDotVercelEnvFiles(t *testing.T) { - tmp := t.TempDir() - logPath := filepath.Join(tmp, "vercel.log") - installFakeVercel(t, tmp, logPath, "https://demo-app-abc123.vercel.app") - - _, err := Apply(context.Background(), ApplyInput{ - ProjectDir: tmp, - APIToken: "tok-real", - Env: "prod", - DryRun: false, - InjectedEnv: map[string]string{ - "API_URL": "https://api.example.com", - "FEATURE_FLAGS": "a,b", - }, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - dotVercel := filepath.Join(tmp, ".vercel") - entries, err := os.ReadDir(dotVercel) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("read .vercel: %v", err) - } - for _, e := range entries { - if strings.HasPrefix(e.Name(), ".env") { - t.Fatalf("Apply leaked %s into .vercel/ — local env must stay in cmd.Env, never on disk", e.Name()) - } - } -} - -// containsString is a local helper duplicated from cloudflare's tests. -func containsString(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false -} - -// installFakeVercel writes a shell script under /bin/vercel that -// logs every invocation to logPath and prints urlOnDeploy on the -// `deploy` subcommand. Prepends /bin to $PATH for the duration -// of the test. -func installFakeVercel(t *testing.T, dir, logPath, urlOnDeploy string) { - t.Helper() - bin := filepath.Join(dir, "bin") - if err := os.MkdirAll(bin, 0o755); err != nil { - t.Fatalf("mkdir fake bin: %v", err) - } - if runtime.GOOS == "windows" { - path := filepath.Join(bin, "vercel.cmd") - body := "@echo off\r\n" + - ">>\"%VERCEL_LOG%\" echo %*\r\n" + - "if \"%~1\"==\"deploy\" echo " + urlOnDeploy + "\r\n" - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake vercel: %v", err) - } - t.Setenv("VERCEL_LOG", logPath) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - return - } - path := filepath.Join(bin, "vercel") - body := `#!/bin/sh -echo "$@" >> "$VERCEL_LOG" -case "$1" in - deploy) - printf '` + urlOnDeploy + `\n' - ;; - *) - : - ;; -esac -` - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake vercel: %v", err) - } - t.Setenv("VERCEL_LOG", logPath) - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) -} diff --git a/packages/cli/internal/adapters/deploy/vercel/provider.go b/packages/cli/internal/adapters/deploy/vercel/provider.go deleted file mode 100644 index 25586995..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/provider.go +++ /dev/null @@ -1,96 +0,0 @@ -package vercel - -// provider.go is the deploy/vercel adapter onto the deploy.Provider -// interface. Apply pulls the API token + team scope from the resolved -// profile, reads the per-project env name from the manifest, and shells -// out via ops.go's Apply. - -import ( - "context" - "path/filepath" - "sort" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -type providerImpl struct{} - -func Provider() deploy.Provider { return providerImpl{} } - -func (providerImpl) ID() string { return workspace.DeployBackendVercel } - -func (providerImpl) Apply(ctx context.Context, in deploy.ApplyInput) (*deploy.ApplyResult, error) { - if in.Resolved == nil || in.Resolved.Profile.Vercel == nil { - return nil, cliErrors.New(cliErrors.VERCEL_PROFILE_INVALID, - "deploy/vercel 需要先配置一个 deploy/vercel profile。先 `one configure add deploy/vercel --profile --use`。") - } - vp := in.Resolved.Profile.Vercel - if vp.Credentials == nil || vp.Credentials.APIToken == "" { - return nil, cliErrors.New(cliErrors.VERCEL_PROFILE_INVALID, - "deploy/vercel profile 缺 API token。在 vercel.com → Account Settings → Tokens 创建后重新跑 `one configure add deploy/vercel --profile `。") - } - - projectDir := projectDirFor(in) - envName := envForProject(in.Manifest, in.Project.Name) - - res, err := Apply(ctx, ApplyInput{ - ProjectDir: projectDir, - APIToken: vp.Credentials.APIToken, - Team: vp.Team, - Env: envName, - DryRun: in.DryRun, - InjectedEnv: in.InjectedEnv, - }) - if err != nil { - return nil, err - } - if res == nil { - return nil, nil - } - return &deploy.ApplyResult{ - Schema: res.Schema, - Argv: res.Argv, - CommandLines: res.CommandLines, - DryRun: res.DryRun, - InjectedEnvKeys: sortedKeys(in.InjectedEnv), - InjectedEnvSource: in.InjectedEnvSource, - }, nil -} - -// sortedKeys returns the map's keys in alphabetical order. Returns nil -// for nil / empty input so the resulting ApplyResult field is omitted -// from the JSON envelope when there is no injection. -func sortedKeys(m map[string]string) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// projectDirFor returns the absolute filesystem dir for the project. -// Project.TargetDir is set by deploycmd; fall through to ProjectRoot + -// RelativeDir for callers that don't pre-resolve it. -func projectDirFor(in deploy.ApplyInput) string { - if in.Project.TargetDir != "" { - return in.Project.TargetDir - } - return filepath.Join(in.ProjectRoot, filepath.FromSlash(in.Project.RelativeDir)) -} - -// envForProject reads projects[i].domains.deploy.config.env. Empty when -// the manifest does not pin a value; ops.isProduction treats empty as the -// production tier, preserving the prior "default to production" behaviour. -func envForProject(m *workspace.Manifest, projectName string) string { - cfg, _ := DecodeProjectConfig(m, projectName) - if cfg == nil { - return "" - } - return cfg.Env -} diff --git a/packages/cli/internal/adapters/deploy/vercel/provider_test.go b/packages/cli/internal/adapters/deploy/vercel/provider_test.go deleted file mode 100644 index 8ac38708..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/provider_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package vercel - -import ( - "context" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -func TestProviderFactory(t *testing.T) { - p := Provider() - if p.ID() != "vercel" { - t.Fatalf("provider ID = %q, want vercel", p.ID()) - } -} - -// envForProject: returns the per-project Env pin verbatim, or empty -// when the manifest does not declare one. Empty defaults to production -// at the ops layer via isProduction. -func TestEnvForProject(t *testing.T) { - tests := []struct { - name string - m *workspace.Manifest - want string - }{ - { - name: "nil manifest yields empty (production default)", - m: nil, - want: "", - }, - { - name: "missing project yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "other"}}}, - want: "", - }, - { - name: "project without deploy section yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web"}, - }}, - want: "", - }, - { - name: "project without vercel config yields empty", - m: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "vercel"}, - }}, - }}, - want: "", - }, - { - name: "explicit env=prod returns prod", - m: manifestWithVercelEnv(t, "prod"), - want: "prod", - }, - { - name: "explicit env=staging returns staging", - m: manifestWithVercelEnv(t, "staging"), - want: "staging", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := envForProject(tt.m, "web") - if got != tt.want { - t.Fatalf("envForProject = %q, want %q", got, tt.want) - } - }) - } -} - -// isProduction collapses the user-facing env name to the production / -// preview tier the upstream Vercel CLI exposes. -func TestIsProduction(t *testing.T) { - cases := []struct { - env string - want bool - }{ - {"", true}, // unset default = production - {"prod", true}, // explicit prod = production - {"dev", false}, // anything else = preview - {"staging", false}, - {"qa", false}, - {" prod ", true}, // trimmed - } - for _, c := range cases { - t.Run(c.env, func(t *testing.T) { - if got := isProduction(c.env); got != c.want { - t.Fatalf("isProduction(%q) = %v, want %v", c.env, got, c.want) - } - }) - } -} - -// projectDirFor honours an explicit TargetDir, otherwise joins -// ProjectRoot + RelativeDir. -func TestProjectDirFor(t *testing.T) { - withTarget := deploy.ApplyInput{ - ProjectRoot: "/repo", - Project: workspace.Project{ - Name: "web", - RelativeDir: "apps/web", - TargetDir: "/some/explicit/path", - }, - } - if got := projectDirFor(withTarget); got != "/some/explicit/path" { - t.Errorf("explicit TargetDir not honoured: got %q", got) - } - withoutTarget := deploy.ApplyInput{ - ProjectRoot: "/repo", - Project: workspace.Project{ - Name: "web", - RelativeDir: "apps/web", - }, - } - if want, got := filepath.Join("/repo", "apps", "web"), projectDirFor(withoutTarget); got != want { - t.Errorf("fallback path = %q, want %q", got, want) - } -} - -// Provider.Apply must reject an input with no resolved profile — -// without the API token there's nothing to authenticate as. -func TestProviderApplyMissingProfileSurfacesVercelProfileInvalid(t *testing.T) { - p := providerImpl{} - _, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{}, - Resolved: nil, - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for nil Resolved") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "VERCEL_PROFILE_INVALID" { - t.Fatalf("error = %v, want VERCEL_PROFILE_INVALID", err) - } -} - -// Provider.Apply also rejects a resolved profile whose Credentials -// pointer is nil or whose APIToken is empty — same VERCEL_PROFILE_INVALID -// code, different remediation phrasing inside the message. -func TestProviderApplyEmptyTokenSurfacesVercelProfileInvalid(t *testing.T) { - p := providerImpl{} - _, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{}, - Resolved: &profile.Resolved{ - Name: "default", - Profile: profile.Profile{Backend: "vercel", Vercel: &profile.VercelProfile{}}, - }, - DryRun: true, - }) - if err == nil { - t.Fatal("expected error for empty token") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "VERCEL_PROFILE_INVALID" { - t.Fatalf("error = %v, want VERCEL_PROFILE_INVALID", err) - } -} - -// Provider.Apply happy path (dry-run): a resolved profile with token + -// team threads through to the underlying Apply, returns the deploy.ApplyResult -// envelope with masked argv. -func TestProviderApplyDryRunReturnsMaskedEnvelope(t *testing.T) { - p := providerImpl{} - res, err := p.Apply(context.Background(), deploy.ApplyInput{ - ProjectRoot: t.TempDir(), - Project: workspace.Project{Name: "web", RelativeDir: "apps/web"}, - Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: "vercel"}, - }}, - }}, - Resolved: &profile.Resolved{ - Name: "default", - Profile: profile.Profile{ - Backend: "vercel", - Vercel: &profile.VercelProfile{ - Team: "acme", - Credentials: &profile.VercelCredentials{APIToken: "secret-tok-1"}, - }, - }, - }, - DryRun: true, - }) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if res == nil { - t.Fatal("nil result") - } - if !res.DryRun { - t.Errorf("DryRun = false, want true") - } - if res.Schema != SchemaApply { - t.Errorf("Schema = %q, want %q", res.Schema, SchemaApply) - } - for _, a := range res.Argv { - if contains(a, "secret-tok-1") { - t.Fatalf("argv leaked token: %v", res.Argv) - } - } -} - -func contains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} - -// manifestWithVercelEnv builds a one-project manifest with a -// per-project Vercel deploy section pinned to the given env name. -// Test-only helper that exercises EncodeProjectConfig so the wire shape -// is identical to what the provider writes during real invocations. -func manifestWithVercelEnv(t *testing.T, env string) *workspace.Manifest { - t.Helper() - p := workspace.ManifestProject{Name: "web"} - if err := EncodeProjectConfig(&p, &ProjectConfig{Env: env}); err != nil { - t.Fatalf("EncodeProjectConfig: %v", err) - } - return &workspace.Manifest{Projects: []workspace.ManifestProject{p}} -} diff --git a/packages/cli/internal/adapters/deploy/vercel/sync.go b/packages/cli/internal/adapters/deploy/vercel/sync.go deleted file mode 100644 index 1d644a19..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/sync.go +++ /dev/null @@ -1,56 +0,0 @@ -package vercel - -// sync.go scaffolds the project-side `vercel.json` file the first time -// a project picks deploy/vercel. Vercel's framework auto-detection -// covers most of what users need; we still write an explicit framework -// hint so first-run `vercel pull --yes` doesn't have to ask. - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" -) - -// VercelConfigFilename is the canonical config file vercel CLI reads. -const VercelConfigFilename = "vercel.json" - -// ShouldSync reports whether a Sync call would do work — only when no -// vercel.json is present (we never overwrite a hand-edited file). -func ShouldSync(projectDir string) bool { - _, err := os.Stat(filepath.Join(projectDir, VercelConfigFilename)) - return errors.Is(err, fs.ErrNotExist) -} - -// Sync writes a minimal vercel.json with a framework hint guessed from -// the template id. Idempotent: existing files are left alone. -func Sync(projectDir, templateID string) error { - target := filepath.Join(projectDir, VercelConfigFilename) - if !ShouldSync(projectDir) { - return nil - } - cfg := defaultConfig(templateID) - raw, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - raw = append(raw, '\n') - return os.WriteFile(target, raw, 0o644) -} - -// defaultConfig maps One CLI template ids to vercel.json framework -// hints. The list is deliberately short — users on unrecognised -// templates get an empty `{}` file and rely on Vercel's framework -// auto-detection. -func defaultConfig(templateID string) map[string]any { - switch templateID { - case "nextjs-app": - return map[string]any{"framework": "nextjs"} - case "react-spa": - return map[string]any{"framework": "vite"} - case "astro-site", "starlight-docs": - return map[string]any{"framework": "astro"} - } - return map[string]any{} -} diff --git a/packages/cli/internal/adapters/deploy/vercel/sync_test.go b/packages/cli/internal/adapters/deploy/vercel/sync_test.go deleted file mode 100644 index e1e99208..00000000 --- a/packages/cli/internal/adapters/deploy/vercel/sync_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package vercel - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestShouldSyncTrueWhenMissing(t *testing.T) { - if !ShouldSync(t.TempDir()) { - t.Errorf("ShouldSync on empty dir = false, want true") - } -} - -func TestShouldSyncFalseWhenPresent(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, VercelConfigFilename), []byte("{}\n"), 0o644); err != nil { - t.Fatalf("seed vercel.json: %v", err) - } - if ShouldSync(dir) { - t.Errorf("ShouldSync with existing vercel.json = true, want false") - } -} - -func TestSyncWritesNextjsForNextTemplate(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "nextjs-app"); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readVercelJSON(t, dir) - want := map[string]any{"framework": "nextjs"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("vercel.json = %v, want %v", got, want) - } -} - -func TestSyncWritesViteForReactCsrTemplate(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "react-spa"); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readVercelJSON(t, dir) - want := map[string]any{"framework": "vite"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("vercel.json = %v, want %v", got, want) - } -} - -func TestSyncWritesAstroForAstroTemplates(t *testing.T) { - for _, tpl := range []string{"astro-site", "starlight-docs"} { - t.Run(tpl, func(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, tpl); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readVercelJSON(t, dir) - want := map[string]any{"framework": "astro"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("vercel.json = %v, want %v", got, want) - } - }) - } -} - -func TestSyncWritesEmptyConfigForUnknownTemplate(t *testing.T) { - dir := t.TempDir() - if err := Sync(dir, "some-unknown-template"); err != nil { - t.Fatalf("Sync: %v", err) - } - got := readVercelJSON(t, dir) - if len(got) != 0 { - t.Fatalf("unknown template should produce empty config, got %v", got) - } -} - -func TestSyncIsIdempotent(t *testing.T) { - dir := t.TempDir() - // Pre-existing config should not be overwritten. - pre := []byte(`{"framework":"custom"}` + "\n") - if err := os.WriteFile(filepath.Join(dir, VercelConfigFilename), pre, 0o644); err != nil { - t.Fatalf("seed: %v", err) - } - if err := Sync(dir, "nextjs-app"); err != nil { - t.Fatalf("Sync: %v", err) - } - raw, err := os.ReadFile(filepath.Join(dir, VercelConfigFilename)) - if err != nil { - t.Fatalf("read: %v", err) - } - if string(raw) != string(pre) { - t.Fatalf("Sync overwrote existing vercel.json:\nbefore: %s\nafter: %s", pre, raw) - } -} - -func readVercelJSON(t *testing.T, dir string) map[string]any { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, VercelConfigFilename)) - if err != nil { - t.Fatalf("read vercel.json: %v", err) - } - var v map[string]any - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("unmarshal vercel.json: %v", err) - } - return v -} diff --git a/packages/cli/internal/adapters/env/dotenv/ops.go b/packages/cli/internal/adapters/env/dotenv/ops.go index 15faea4a..f911c5ac 100644 --- a/packages/cli/internal/adapters/env/dotenv/ops.go +++ b/packages/cli/internal/adapters/env/dotenv/ops.go @@ -139,10 +139,10 @@ func Get(in GetInput) (*GetResult, error) { if !ok { where := strings.Join(sources, " + ") if where == "" { - where = "未找到 .env" + where = i18n.T("dotenv.no_file") } return nil, cliErrors.New(cliErrors.ENV_KEY_NOT_FOUND, - fmt.Sprintf("KEY %q 不在 dotenv 文件中(%s)", in.Key, where)) + i18n.Tf("dotenv.key_missing", in.Key, where)) } // Source = the last file in the overlay that defined this key. source := sources[len(sources)-1] @@ -194,7 +194,7 @@ func List(in ListInput) (*ListResult, error) { func Set(in SetInput) (*SetResult, error) { if strings.TrimSpace(in.Key) == "" { return nil, cliErrors.New(cliErrors.ENV_SET_KEY_REQUIRED, - "必须提供 位置参数。") + i18n.T("env.key_required")) } subDir, err := resolveSubprojectDir(in.ProjectRoot, in.SubprojectPath) if err != nil { @@ -205,7 +205,7 @@ func Set(in SetInput) (*SetResult, error) { existing, found, err := LoadDotenvFile(target) if err != nil { return nil, cliErrors.New(cliErrors.RUN_DOTENV_MISSING, - fmt.Sprintf("读取 %s 失败:%v", target, err)) + i18n.Tf("file.read_failed", target, err)) } action := "created" @@ -222,7 +222,7 @@ func Set(in SetInput) (*SetResult, error) { } if !in.Overwrite { return nil, cliErrors.New(cliErrors.ENV_SET_OVERWRITE_REQUIRED, - "密钥 "+in.Key+" 已存在且值不同。加 --yes 确认覆盖。"). + i18n.Tf("env.key_overwrite", in.Key)). WithContext(map[string]any{ "source": target, "env": in.Env, @@ -281,7 +281,7 @@ func loadOverlay(chain []string) (map[string]string, []string, error) { vars, found, err := LoadDotenvFile(path) if err != nil { return nil, nil, cliErrors.New(cliErrors.RUN_DOTENV_MISSING, - fmt.Sprintf("读取 %s 失败:%v", path, err)) + i18n.Tf("file.read_failed", path, err)) } if !found { continue diff --git a/packages/cli/internal/adapters/env/infisical/auth.go b/packages/cli/internal/adapters/env/infisical/auth.go index dffdb541..359c73d5 100644 --- a/packages/cli/internal/adapters/env/infisical/auth.go +++ b/packages/cli/internal/adapters/env/infisical/auth.go @@ -1,30 +1,4 @@ package infisical -// auth.go is just the credential type now. Credentials are sourced -// exclusively through the machine-level profile system -// (~/.config/one/config.json + credentials.json) — see run_profile.go's -// resolveProfileCreds. The previous env-var path -// (INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/_SECRET) was retired because: -// -// - Two sources meant subtle "which one wins?" confusion. Users who -// configured a profile then saw stale env vars from their dotfile -// beat the explicit profile. -// - Profiles already cover the CI use-case via the standard -// precedence chain: `--profile` flag or manifest.domains.env.profile -// selects which profile to use; the credentials live inside the -// profile (which is per-user, mode 0600). -// -// CI migration: replace -// export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=... -// export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=... -// with one of: -// one configure add env/infisical --profile ci \ -// --client-id $CID --client-secret $CSEC --use -// or pre-bake config.json + credentials.json into the runner image. - -// Credentials is the canonical Universal Auth credential pair. Built -// from a resolved profile, never read from the environment. -type Credentials struct { - ClientID string - ClientSecret string -} +// Credentials holds the active browser session in memory. Never serialize it. +type Credentials struct{ AccessToken string } diff --git a/packages/cli/internal/adapters/env/infisical/client.go b/packages/cli/internal/adapters/env/infisical/client.go index fc8c077f..557528c0 100644 --- a/packages/cli/internal/adapters/env/infisical/client.go +++ b/packages/cli/internal/adapters/env/infisical/client.go @@ -2,27 +2,20 @@ package infisical import ( "context" - "fmt" + "io" "net" "regexp" "strings" - "time" infisical "github.com/infisical/go-sdk" "github.com/infisical/go-sdk/packages/models" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" ) -// Client is the thin wrapper around the Infisical SDK that the rest of the -// secrets package uses. Its purpose is centralising error mapping (SDK -// errors → cliErrors.Code) so the cobra commands stay focused on UX. -// -// accessToken is captured after a successful UniversalAuthLogin so the -// raw-HTTP project-creation path (CreateProject) can reach it without -// going back through the SDK's Auth interface — this also makes the type -// trivially mockable in tests. +// Client wraps the SDK with the current browser session. type Client struct { sdk infisical.InfisicalClientInterface cfg *WorkspaceConfig @@ -30,58 +23,14 @@ type Client struct { accessToken string } -// NewClient builds an authenticated client. Network IO happens here: -// UniversalAuthLogin contacts Infisical to exchange the client id+secret -// for an access token. Errors are mapped to typed cliErrors so the JSON -// envelope reaches the agent with the right code. -// -// Caching: when cfg.ProfileName is set, we first try to reuse a recent -// access token from ~/.config/one/cache/env/infisical/.json. -// On hit we feed it into the SDK via SetAccessToken and skip the login -// round-trip entirely. On miss / expired / parse failure / cache I/O -// failure we transparently fall through to UniversalAuthLogin and -// (best-effort) refresh the cache afterwards. Cache miss-on-401 is -// not auto-retried in the first version: if a cached token is -// rejected at first use, the resulting INFISICAL_AUTH_FAILED reaches -// the user with a hint to clear the cache or rotate creds. Adding -// retry-with-clear-on-401 is a future iteration. func NewClient(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials) (*Client, error) { - sdk := infisical.NewInfisicalClient(ctx, infisical.Config{ - SiteUrl: cfg.SiteURLOrDefault(), - UserAgent: "one-cli/" + clientVersion, - SilentMode: true, - }) - profileName := strings.TrimSpace(cfg.ProfileName) - if profileName != "" { - if entry, _ := profile.ReadCache(profile.DomainEnv, "infisical", profileName); entry != nil && entry.Token != "" { - sdk.Auth().SetAccessToken(entry.Token) - return &Client{ - sdk: sdk, - cfg: cfg, - credentials: creds, - accessToken: entry.Token, - }, nil - } - } - loginResp, err := sdk.Auth().UniversalAuthLogin(creds.ClientID, creds.ClientSecret) - if err != nil { - return nil, mapAuthError(err) - } - if profileName != "" && loginResp.AccessToken != "" { - now := time.Now().UTC() - _ = profile.WriteCache(profile.DomainEnv, "infisical", profileName, &profile.CacheEntry{ - Token: loginResp.AccessToken, - TokenType: loginResp.TokenType, - ExpiresAt: now.Add(time.Duration(loginResp.ExpiresIn) * time.Second), - SavedAt: now, - }) + if creds == nil || creds.AccessToken == "" { + return nil, session.Missing() } - return &Client{ - sdk: sdk, - cfg: cfg, - credentials: creds, - accessToken: sdk.Auth().GetAccessToken(), - }, nil + autoRefresh := false + sdk := infisical.NewInfisicalClient(ctx, infisical.Config{SiteUrl: cfg.SiteURLOrDefault(), UserAgent: "one-cli/" + clientVersion, SilentMode: true, LogWriter: io.Discard, AutoTokenRefresh: &autoRefresh}) + sdk.Auth().SetAccessToken(creds.AccessToken) + return &Client{sdk: sdk, cfg: cfg, credentials: creds, accessToken: creds.AccessToken}, nil } // clientVersion is overridden at link-time via -ldflags. We don't bother @@ -124,7 +73,7 @@ func (c *Client) RetrieveSecret(env, secretPath, key string) (*models.Secret, er mapped := mapAPIError(err) if isNotFound(err) { return nil, cliErrors.New(cliErrors.ENV_KEY_NOT_FOUND, - "密钥不存在: "+key) + i18n.Tf("env.key_missing", key)) } return nil, mapped } @@ -229,7 +178,7 @@ func (c *Client) DeleteSecret(env, secretPath, key string) (*models.Secret, erro }) if err != nil { if isNotFound(err) { - return nil, cliErrors.New(cliErrors.ENV_KEY_NOT_FOUND, "密钥不存在: "+key) + return nil, cliErrors.New(cliErrors.ENV_KEY_NOT_FOUND, i18n.Tf("env.key_missing", key)) } return nil, mapAPIError(err) } @@ -251,7 +200,7 @@ func (c *Client) VerifyProjectExists(env string) error { } if isNotFound(err) { return cliErrors.New(cliErrors.INFISICAL_PROJECT_NOT_FOUND, - "找不到 Infisical 项目: "+c.cfg.ProjectID). + i18n.Tf("infisical.project_missing", c.cfg.ProjectID)). WithContext(map[string]any{"project_id": c.cfg.ProjectID, "site_url": c.cfg.SiteURLOrDefault()}) } return mapAPIError(err) @@ -259,47 +208,22 @@ func (c *Client) VerifyProjectExists(env string) error { // ----- error helpers below ----- -func mapAuthError(err error) error { - msg := err.Error() - lower := strings.ToLower(msg) - switch { - case isNetworkError(err): - return cliErrors.New(cliErrors.INFISICAL_NETWORK_ERROR, - "无法连接到 Infisical:"+msg) - case strings.Contains(lower, "invalid credential") || - strings.Contains(lower, "unauthorized") || - strings.Contains(lower, "401"): - return cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, - "Infisical 拒绝了凭据:请确认 client id / secret 正确且未过期。") - default: - return cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, - "Infisical 登录失败:"+msg) - } -} - +func mapAuthError(err error) error { return mapAPIError(err) } func mapAPIError(err error) error { if err == nil { return nil } if isNetworkError(err) { - return cliErrors.New(cliErrors.INFISICAL_NETWORK_ERROR, - "无法连接到 Infisical:"+err.Error()) + return cliErrors.New(cliErrors.INFISICAL_NETWORK_ERROR, i18n.T("infisical.network")) } - if folder, env := parseFolderNotFound(err); folder != "" { - return cliErrors.New(cliErrors.INFISICAL_FOLDER_NOT_FOUND, - fmt.Sprintf("Infisical 中找不到 folder %q(环境=%s)。检查 --env 名是否正确,或先 `one env set --env %s -p %s KEY value` 创建。", - folder, env, env, strings.TrimPrefix(folder, "/"))). - WithContext(map[string]any{ - "folder": folder, - "environment": env, - }) + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "401") || strings.Contains(lower, "unauthorized") { + return cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, i18n.T("infisical.relogin")) } - if isNotFound(err) { - return cliErrors.New(cliErrors.INFISICAL_API_ERROR, - "Infisical 资源不存在: "+err.Error()) + if folder, env := parseFolderNotFound(err); folder != "" { + return cliErrors.New(cliErrors.INFISICAL_FOLDER_NOT_FOUND, i18n.Tf("infisical.folder_missing", env, folder)) } - return cliErrors.New(cliErrors.INFISICAL_API_ERROR, err.Error()). - WithContext(map[string]any{"underlying": err.Error()}) + return cliErrors.New(cliErrors.INFISICAL_API_ERROR, i18n.T("infisical.request_failed")) } // folderNotFoundRE matches Infisical's folder-404 message shape: diff --git a/packages/cli/internal/adapters/env/infisical/client_projects.go b/packages/cli/internal/adapters/env/infisical/client_projects.go index 24e4f074..5bdfd399 100644 --- a/packages/cli/internal/adapters/env/infisical/client_projects.go +++ b/packages/cli/internal/adapters/env/infisical/client_projects.go @@ -2,33 +2,38 @@ package infisical import ( "bytes" + "context" "encoding/json" - "fmt" "io" "net/http" "strings" "time" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // CreateProject calls Infisical's POST /api/v2/workspace endpoint to create // a new secret-manager project named `projectName`. It does not use the // Infisical Go SDK because the SDK's public surface is secrets-only — we -// reach into the access token the SDK already obtained via UniversalAuthLogin +// reuse the active browser session access token // and issue the HTTP request directly. // // Returned (id, resolvedName) reflect what Infisical actually accepted; the // caller may have to retry with a suffix when the API surfaces a name // collision (INFISICAL_PROJECT_NAME_TAKEN). func (c *Client) CreateProject(projectName string) (string, string, error) { + return c.CreateProjectContext(context.Background(), projectName) +} + +func (c *Client) CreateProjectContext(ctx context.Context, projectName string) (string, string, error) { token := c.accessToken if token == "" && c.sdk != nil { token = c.sdk.Auth().GetAccessToken() } if token == "" { return "", "", cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, - "Infisical access token 不可用,无法调用 create-project。") + i18n.T("infisical.token_missing")) } body, err := json.Marshal(map[string]any{ @@ -40,7 +45,7 @@ func (c *Client) CreateProject(projectName string) (string, string, error) { } url := strings.TrimRight(c.cfg.SiteURLOrDefault(), "/") + "/api/v2/workspace" - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return "", "", err } @@ -49,18 +54,18 @@ func (c *Client) CreateProject(projectName string) (string, string, error) { req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", "one-cli/"+clientVersion) - httpClient := &http.Client{Timeout: 30 * time.Second} + httpClient := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := httpClient.Do(req) if err != nil { if isNetworkError(err) { return "", "", cliErrors.New(cliErrors.INFISICAL_NETWORK_ERROR, - "无法连接到 Infisical:"+err.Error()) + i18n.T("infisical.unreachable")) } - return "", "", cliErrors.New(cliErrors.INFISICAL_API_ERROR, err.Error()) + return "", "", cliErrors.New(cliErrors.INFISICAL_API_ERROR, i18n.T("infisical.request_error")) } defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: @@ -72,13 +77,11 @@ func (c *Client) CreateProject(projectName string) (string, string, error) { } if err := json.Unmarshal(respBody, &parsed); err != nil { return "", "", cliErrors.New(cliErrors.INFISICAL_API_ERROR, - "Infisical create-project 响应解析失败:"+err.Error()). - WithContext(map[string]any{"body": string(respBody)}) + i18n.Tf("infisical.create_response_invalid", err.Error())) } if parsed.Project.ID == "" { return "", "", cliErrors.New(cliErrors.INFISICAL_API_ERROR, - "Infisical create-project 响应缺少 project.id"). - WithContext(map[string]any{"body": string(respBody)}) + i18n.T("infisical.create_missing_id")) } name := parsed.Project.Name if name == "" { @@ -88,13 +91,11 @@ func (c *Client) CreateProject(projectName string) (string, string, error) { case resp.StatusCode == http.StatusForbidden: return "", "", cliErrors.New(cliErrors.INFISICAL_PROJECT_CREATE_FORBIDDEN, - "Infisical 拒绝创建项目(403)。机器身份缺少 create-project 权限。"). - WithContext(map[string]any{"body": string(respBody)}) + i18n.T("infisical.create_forbidden")) case resp.StatusCode == http.StatusUnauthorized: return "", "", cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, - "Infisical 拒绝了访问令牌(401)。"). - WithContext(map[string]any{"body": string(respBody)}) + i18n.T("infisical.token_rejected")) default: // Look for known name-collision signals in the body before falling @@ -106,11 +107,11 @@ func (c *Client) CreateProject(projectName string) (string, string, error) { strings.Contains(lower, "name is already taken") || strings.Contains(lower, "duplicate") { return "", "", cliErrors.New(cliErrors.INFISICAL_PROJECT_NAME_TAKEN, - "Infisical 项目名 "+projectName+" 已被占用"). - WithContext(map[string]any{"status": resp.StatusCode, "body": string(respBody)}) + i18n.Tf("infisical.project_name_taken", projectName)). + WithContext(map[string]any{"status": resp.StatusCode}) } return "", "", cliErrors.New(cliErrors.INFISICAL_API_ERROR, - fmt.Sprintf("Infisical create-project 失败(HTTP %d)", resp.StatusCode)). - WithContext(map[string]any{"status": resp.StatusCode, "body": string(respBody)}) + i18n.Tf("infisical.create_failed", resp.StatusCode)). + WithContext(map[string]any{"status": resp.StatusCode}) } } diff --git a/packages/cli/internal/adapters/env/infisical/config.go b/packages/cli/internal/adapters/env/infisical/config.go index 45bde84e..dbe2dd54 100644 --- a/packages/cli/internal/adapters/env/infisical/config.go +++ b/packages/cli/internal/adapters/env/infisical/config.go @@ -12,10 +12,11 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // DefaultSiteURL is the public Infisical SaaS instance. Workspaces using a -// self-hosted instance must set siteUrl explicitly via `one configure add env/infisical --site-url`. +// self-hosted instance must set siteUrl explicitly via `one login --site-url`. const DefaultSiteURL = "https://app.infisical.com" // DefaultEnvironment is the canonical first environment every workspace @@ -42,12 +43,6 @@ type WorkspaceConfig struct { DefaultEnv string RootPath string Keys []string - - // ProfileName is the resolved env/infisical profile name powering - // this config. Runtime-only (never persisted to manifest); set by - // the resolver path so the SDK client can key its short-lived - // access-token cache by (env, infisical, ProfileName). - ProfileName string } // SubprojectConfig is the (optional) per-subproject override stored on the @@ -69,6 +64,7 @@ type SubprojectConfig struct { // `manifest.domains.env.config` when kind == "infisical". Backend-specific // fields plus the shared workspace-tracked variable-name list. type manifestEnvConfig struct { + SiteURL string `json:"siteUrl,omitempty"` ProjectID string `json:"projectId,omitempty"` ProjectName string `json:"projectName,omitempty"` RootPath string `json:"rootPath,omitempty"` @@ -129,6 +125,7 @@ func LoadWorkspaceConfig(projectRoot string) (*WorkspaceConfig, error) { if err := json.Unmarshal(m.Domains.Env.Config, &raw); err != nil { return nil, err } + cfg.SiteURL = raw.SiteURL cfg.ProjectID = raw.ProjectID cfg.ProjectName = raw.ProjectName cfg.RootPath = raw.RootPath @@ -141,23 +138,7 @@ func LoadWorkspaceConfig(projectRoot string) (*WorkspaceConfig, error) { return cfg, nil } -// resolveCfgAndCreds is the v0.5+ adapter helper. Profile-level and -// manifest-level concerns are merged here: -// -// - Manifest (one.manifest.json) is the source of truth for project-level -// fields: ProjectID, ProjectName, Environments, DefaultEnv, RootPath. -// Always read. -// - Profile (~/.config/one/config.json + credentials.json) contributes -// machine-level fields: SiteURL + credentials. When the cobra layer already -// resolved a profile (cfgOverride / credsOverride non-nil), -// they're applied directly. Otherwise we resolve here so the same -// "profile is the only source" rule holds for callers (e.g. some -// internal helpers) that don't go through envcmd. -// -// Splitting the scopes this way means a single profile drives many -// workspaces — each workspace pins its own projectId in its manifest; -// switching profile only switches "which Infisical instance + as -// whom", not "which project". +// resolveCfgAndCreds combines manifest project metadata with the active session. func resolveCfgAndCreds(projectRoot string, cfgOverride *WorkspaceConfig, credsOverride *Credentials) (*WorkspaceConfig, *Credentials, error) { cfg, err := RequireWorkspaceConfig(projectRoot) if err != nil { @@ -165,24 +146,26 @@ func resolveCfgAndCreds(projectRoot string, cfgOverride *WorkspaceConfig, credsO } if cfgOverride != nil { if strings.TrimSpace(cfgOverride.SiteURL) != "" { + if cfg.SiteURL != "" && cfg.SiteURL != cfgOverride.SiteURL { + return nil, nil, cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, i18n.T("infisical.binding_account_mismatch")) + } cfg.SiteURL = cfgOverride.SiteURL } } creds := credsOverride if creds == nil { - // No upstream profile resolution — do it here. Errors with - // INFISICAL_AUTH_MISSING when no profile is configured. - profileName, c, siteURL, err := requireProfileCreds(projectRoot, "") + // Resolve the browser session when the caller did not provide credentials. + c, siteURL, err := sessionCredentials() if err != nil { return nil, nil, err } creds = c - cfg.ProfileName = profileName if cfgOverride == nil && siteURL != "" { + if cfg.SiteURL != "" && cfg.SiteURL != siteURL { + return nil, nil, cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, i18n.T("infisical.binding_instance_mismatch")) + } cfg.SiteURL = siteURL } - } else if cfgOverride != nil && cfgOverride.ProfileName != "" { - cfg.ProfileName = cfgOverride.ProfileName } return cfg, creds, nil } @@ -197,14 +180,11 @@ func RequireWorkspaceConfig(projectRoot string) (*WorkspaceConfig, error) { } if cfg == nil { return nil, cliErrors.New(cliErrors.INFISICAL_NOT_CONFIGURED, - "未找到 Infisical 配置。请在 one.manifest.json#domains.env 中将 kind 设置为 \"infisical\"(机器级凭据通过 `one configure add env/infisical` 配置)。") + i18n.T("infisical.config_missing")) } if strings.TrimSpace(cfg.ProjectID) == "" { return nil, cliErrors.New(cliErrors.INFISICAL_NOT_CONFIGURED, - "当前工作区选择了 Infisical 但还没绑定项目(manifest.domains.env.config.projectId 为空)。"+ - "\n→ 确认已配置 `one configure add env/infisical --profile --use`,"+ - "\n 然后重新运行 `one env get/set/list/pull` 触发 lazy auto-bind。"+ - "\n (如果你只想用本地 .env,可以把 manifest.domains.env.kind 改成 \"dotenv\"。)") + i18n.T("infisical.binding_missing")) } return cfg, nil } @@ -243,6 +223,7 @@ func LoadSubprojectConfig(projectRoot, relativeDir string) (*SubprojectConfig, e // a freshly-resolved workspace setup back to disk. func EncodeManifestConfig(cfg *WorkspaceConfig) (json.RawMessage, error) { raw := manifestEnvConfig{ + SiteURL: cfg.SiteURL, ProjectID: cfg.ProjectID, ProjectName: cfg.ProjectName, RootPath: cfg.RootPath, diff --git a/packages/cli/internal/adapters/env/infisical/crud.go b/packages/cli/internal/adapters/env/infisical/crud.go index 3bbc2dff..3a600cf7 100644 --- a/packages/cli/internal/adapters/env/infisical/crud.go +++ b/packages/cli/internal/adapters/env/infisical/crud.go @@ -60,7 +60,7 @@ func Set(ctx context.Context, projectRoot string, in SetInput) (*SetResult, erro } if strings.TrimSpace(in.Key) == "" { return nil, cliErrors.New(cliErrors.ENV_SET_KEY_REQUIRED, - "必须提供 位置参数。") + i18n.T("env.key_required")) } if err := AssertValidKey(in.Key); err != nil { return nil, err @@ -108,7 +108,7 @@ func Set(ctx context.Context, projectRoot string, in SetInput) (*SetResult, erro } if !in.Overwrite { return nil, cliErrors.New(cliErrors.ENV_SET_OVERWRITE_REQUIRED, - "密钥 "+in.Key+" 已存在且值不同。加 --yes 确认覆盖。"). + i18n.Tf("env.key_overwrite", in.Key)). WithContext(map[string]any{ "env": env, "path": path, "key": in.Key, }) @@ -160,7 +160,7 @@ func Get(ctx context.Context, projectRoot string, in GetInput) (*GetResult, erro } if strings.TrimSpace(in.Key) == "" { return nil, cliErrors.New(cliErrors.ENV_SET_KEY_REQUIRED, - "必须提供 位置参数。") + i18n.T("env.key_required")) } env, err := SanitizeEnvName(envOrDefault(in.Env, cfg.DefaultEnvOrFallback())) if err != nil { @@ -228,7 +228,7 @@ func Delete(ctx context.Context, projectRoot string, in DeleteInput) (*DeleteRes return nil, err } if strings.TrimSpace(in.Key) == "" { - return nil, cliErrors.New(cliErrors.ENV_SET_KEY_REQUIRED, "必须提供密钥名。") + return nil, cliErrors.New(cliErrors.ENV_SET_KEY_REQUIRED, i18n.T("env.name_required")) } if err := AssertValidKey(in.Key); err != nil { return nil, err diff --git a/packages/cli/internal/adapters/env/infisical/fetch.go b/packages/cli/internal/adapters/env/infisical/fetch.go index 6d6c31a8..d30cb3a2 100644 --- a/packages/cli/internal/adapters/env/infisical/fetch.go +++ b/packages/cli/internal/adapters/env/infisical/fetch.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // FetchSecretsForSubproject pulls every secret a subproject can see from @@ -18,10 +19,10 @@ import ( // // Errors propagate raw so callers can branch: // - INFISICAL_NOT_CONFIGURED — workspace's domains.env.config.projectId is unset -// - INFISICAL_AUTH_MISSING — no default env profile / profile has no creds +// - INFISICAL_AUTH_MISSING — no active browser session // - INFISICAL_AUTH_FAILED / INFISICAL_API_ERROR — network / API-level // -// Credentials + siteUrl come exclusively from the resolved env profile. +// Credentials + siteUrl come exclusively from the active browser session. // Env vars are no longer read. func FetchSecretsForSubproject(ctx context.Context, projectRoot, relativeDir, envName string) (map[string]string, error) { cfg, err := RequireWorkspaceConfig(projectRoot) @@ -32,14 +33,14 @@ func FetchSecretsForSubproject(ctx context.Context, projectRoot, relativeDir, en if err != nil { return nil, err } - profileName, creds, siteURL, err := requireProfileCredsForContext( - projectRoot, "", env, manifestProjectName(projectRoot, relativeDir), - ) + creds, siteURL, err := sessionCredentials() if err != nil { return nil, err } + if cfg.SiteURL != "" && cfg.SiteURL != siteURL { + return nil, i18n.Errorf("infisical.binding_instance_mismatch") + } cfg.SiteURL = siteURL - cfg.ProfileName = profileName client, err := NewClient(ctx, cfg, creds) if err != nil { return nil, err @@ -73,26 +74,6 @@ func FetchSecretsForSubproject(ctx context.Context, projectRoot, relativeDir, en return merged, nil } -// manifestProjectName maps the loader's relative directory back to the stable -// project name used by Profile bindings. Unknown/root paths intentionally fall -// back to Workspace scope. -func manifestProjectName(projectRoot, relativeDir string) string { - relativeDir = workspace.ToPosixPath(relativeDir) - if relativeDir == "" || relativeDir == "." { - return "" - } - m, err := workspace.ReadManifest(projectRoot) - if err != nil || m == nil { - return "" - } - for _, project := range m.Projects { - if workspace.ToPosixPath(project.RelativeDir) == relativeDir { - return project.Name - } - } - return "" -} - // isFolderNotFound reports whether err is the structured // INFISICAL_FOLDER_NOT_FOUND envelope (the only "soft" error class in // the chain walk). diff --git a/packages/cli/internal/adapters/env/infisical/global.go b/packages/cli/internal/adapters/env/infisical/global.go new file mode 100644 index 00000000..3ab3ca02 --- /dev/null +++ b/packages/cli/internal/adapters/env/infisical/global.go @@ -0,0 +1,357 @@ +package infisical + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "os" + "path" + "regexp" + "sort" + "strings" + + sdk "github.com/infisical/go-sdk" + "github.com/infisical/go-sdk/packages/models" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" +) + +type RemoteEnvironment struct { + Name string `json:"name"` + Slug string `json:"slug"` +} +type RemoteProject struct { + ID string `json:"id"` + LegacyID string `json:"_id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + OrganizationID string `json:"orgId"` + Environments []RemoteEnvironment `json:"environments"` +} + +func Projects(ctx context.Context) ([]RemoteProject, error) { + s, e := session.Require() + if e != nil { + return nil, e + } + return projectsFor(ctx, s) +} +func projectsFor(ctx context.Context, s *session.Session) ([]RemoteProject, error) { + var result struct { + Projects []RemoteProject `json:"workspaces"` + } + if e := session.Request(ctx, s, http.MethodGet, "/api/v1/workspace", nil, &result); e != nil { + return nil, e + } + projects := []RemoteProject{} + for _, p := range result.Projects { + if p.ID == "" { + p.ID = p.LegacyID + } + p.LegacyID = "" + if p.Type == "secret-manager" && (s.OrganizationID == "" || p.OrganizationID == s.OrganizationID) { + projects = append(projects, p) + } + } + sort.Slice(projects, func(i, j int) bool { return projects[i].Name < projects[j].Name }) + return projects, nil +} +func Project(ctx context.Context, id string) (*RemoteProject, error) { + s, e := session.Require() + if e != nil { + return nil, e + } + return projectFor(ctx, s, id) +} +func projectFor(ctx context.Context, s *session.Session, id string) (*RemoteProject, error) { + if strings.TrimSpace(id) == "" { + return nil, i18n.Errorf("infisical.project_required") + } + var result struct { + Project RemoteProject `json:"workspace"` + } + if e := session.Request(ctx, s, http.MethodGet, "/api/v1/workspace/"+url.PathEscape(id), nil, &result); e != nil { + return nil, e + } + p := result.Project + if p.ID == "" { + p.ID = p.LegacyID + } + p.LegacyID = "" + if p.ID != id { + return nil, i18n.Errorf("infisical.project_response_invalid") + } + if s.OrganizationID != "" && p.OrganizationID != s.OrganizationID { + return nil, i18n.Errorf("infisical.organization_mismatch") + } + if p.Type != "secret-manager" { + return nil, i18n.Errorf("infisical.secret_manager_required") + } + return &p, nil +} + +type GlobalLocation struct { + SiteURL string `json:"siteUrl"` + UserID string `json:"userId"` + OrganizationID string `json:"organizationId"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + DefaultEnvironment string `json:"defaultEnvironment"` +} + +func LoadGlobalLocation() (*GlobalLocation, error) { + p, e := session.ConfigPath("global-env.json") + if e != nil { + return nil, e + } + data, e := os.ReadFile(p) + if os.IsNotExist(e) { + return nil, nil + } + if e != nil { + return nil, e + } + var location GlobalLocation + if json.Unmarshal(data, &location) != nil { + return nil, i18n.Errorf("global.location_invalid") + } + return &location, nil +} +func BindGlobal(ctx context.Context, projectID, env string) (*GlobalLocation, error) { + return withLocationLock(ctx, func() (*GlobalLocation, error) { + s, e := session.Require() + if e != nil { + return nil, e + } + return bindGlobalFor(ctx, s, projectID, env) + }) +} +func bindGlobalFor(ctx context.Context, s *session.Session, projectID, env string) (*GlobalLocation, error) { + p, e := projectFor(ctx, s, projectID) + if e != nil { + return nil, e + } + if e = validateRemoteEnvironment(p, env); e != nil { + return nil, e + } + current, e := session.Require() + if e != nil { + return nil, e + } + if current.SiteURL != s.SiteURL || current.UserID != s.UserID || current.OrganizationID != s.OrganizationID || current.Token != s.Token { + return nil, i18n.Errorf("global.session_changed") + } + location := &GlobalLocation{SiteURL: s.SiteURL, UserID: s.UserID, OrganizationID: p.OrganizationID, ProjectID: p.ID, ProjectName: p.Name, DefaultEnvironment: env} + file, e := session.ConfigPath("global-env.json") + if e != nil { + return nil, e + } + data, _ := json.MarshalIndent(location, "", " ") + if e = fsutil.WriteAtomic(file, append(data, '\n'), 0600); e != nil { + return nil, e + } + return location, nil +} +func validateRemoteEnvironment(p *RemoteProject, env string) error { + for _, v := range p.Environments { + if v.Slug == env { + return nil + } + } + return i18n.Errorf("global.environment_missing", p.Name, env) +} +func ValidateGlobalPath(raw string) (string, error) { + if raw == "" { + raw = "/" + } + if !strings.HasPrefix(raw, "/") || strings.ContainsAny(raw, "\\\x00\r\n") { + return "", i18n.Errorf("global.path_absolute") + } + for _, part := range strings.Split(raw, "/") { + if part == ".." || part == "." { + return "", i18n.Errorf("global.path_segments") + } + } + return path.Clean(raw), nil +} +func globalClient(ctx context.Context, env string) (*Client, *GlobalLocation, string, error) { + s, e := session.Require() + if e != nil { + return nil, nil, "", e + } + location, e := LoadGlobalLocation() + if e != nil { + return nil, nil, "", e + } + if location == nil { + return nil, nil, "", i18n.Errorf("global.location_required") + } + if location.SiteURL != s.SiteURL || location.UserID != s.UserID || (s.OrganizationID != "" && location.OrganizationID != s.OrganizationID) { + return nil, nil, "", i18n.Errorf("global.location_mismatch") + } + p, e := projectFor(ctx, s, location.ProjectID) + if e != nil { + return nil, nil, "", e + } + if p.OrganizationID != location.OrganizationID { + return nil, nil, "", i18n.Errorf("global.organization_changed") + } + if env == "" { + env = location.DefaultEnvironment + } + if e = validateRemoteEnvironment(p, env); e != nil { + return nil, nil, "", e + } + c, e := NewClient(ctx, &WorkspaceConfig{SiteURL: s.SiteURL, ProjectID: p.ID}, &Credentials{AccessToken: s.Token}) + return c, location, env, e +} + +type GlobalEntry struct { + Key string `json:"key"` + Description string `json:"description,omitempty"` +} +type GlobalListing struct { + Location *GlobalLocation `json:"location"` + Environment string `json:"environment"` + Path string `json:"path"` + Folders []string `json:"folders"` + Variables []GlobalEntry `json:"variables"` +} + +func ListGlobal(ctx context.Context, env, folder string) (*GlobalListing, error) { + folder, e := ValidateGlobalPath(folder) + if e != nil { + return nil, e + } + c, l, env, e := globalClient(ctx, env) + if e != nil { + return nil, e + } + dirs, e := c.sdk.Folders().List(sdk.ListFoldersOptions{ProjectID: l.ProjectID, Environment: env, Path: folder}) + if e != nil { + return nil, mapAPIError(e) + } + values, e := c.sdk.Secrets().List(sdk.ListSecretsOptions{ProjectID: l.ProjectID, Environment: env, SecretPath: folder, Recursive: false, ExpandSecretReferences: false}) + if e != nil { + return nil, mapAPIError(e) + } + result := &GlobalListing{Location: l, Environment: env, Path: folder, Folders: []string{}, Variables: []GlobalEntry{}} + for _, d := range dirs { + result.Folders = append(result.Folders, path.Join(folder, d.Name)) + } + for _, v := range values { + result.Variables = append(result.Variables, GlobalEntry{Key: v.SecretKey, Description: v.SecretComment}) + } + sort.Strings(result.Folders) + sort.Slice(result.Variables, func(i, j int) bool { return result.Variables[i].Key < result.Variables[j].Key }) + return result, nil +} + +var envKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func GlobalSecret(ctx context.Context, action, env, folder, key, value string) (any, error) { + if !envKey.MatchString(key) { + return nil, i18n.Errorf("global.key_format") + } + folder, e := ValidateGlobalPath(folder) + if e != nil { + return nil, e + } + c, _, env, e := globalClient(ctx, env) + if e != nil { + return nil, e + } + switch action { + case "get": + v, e := c.retrieveGlobalSecret(env, folder, key) + if e != nil { + return nil, e + } + return map[string]string{"key": key, "value": v.SecretValue, "environment": env, "path": folder}, nil + case "create": + _, e = c.CreateSecret(env, folder, key, value) + case "update": + _, e = c.UpdateSecret(env, folder, key, value) + case "unset": + _, e = c.DeleteSecret(env, folder, key) + default: + return nil, i18n.Errorf("global.operation_unsupported") + } + if e != nil { + return nil, e + } + return map[string]string{"key": key, "environment": env, "path": folder, "action": action}, nil +} +func CreateGlobalFolder(ctx context.Context, env, folder, name string) error { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00\r\n") { + return i18n.Errorf("global.folder_invalid") + } + folder, e := ValidateGlobalPath(folder) + if e != nil { + return e + } + c, l, env, e := globalClient(ctx, env) + if e != nil { + return e + } + _, e = c.sdk.Folders().Create(sdk.CreateFolderOptions{ProjectID: l.ProjectID, Environment: env, Path: folder, Name: name}) + return mapAPIError(e) +} +func GlobalValues(ctx context.Context, env, folder string, keys []string) (map[string]string, error) { + if env == "" || folder == "" { + return nil, i18n.Errorf("global.scope_required") + } + folder, e := ValidateGlobalPath(folder) + if e != nil { + return nil, e + } + c, l, env, e := globalClient(ctx, env) + if e != nil { + return nil, e + } + vars := map[string]string{} + // Selected keys are fetched individually; other values never enter this process. + if len(keys) > 0 { + for _, k := range keys { + if !envKey.MatchString(k) { + return nil, i18n.Errorf("global.key_invalid") + } + v, e := c.retrieveGlobalSecret(env, folder, k) + if e != nil { + return nil, e + } + vars[k] = v.SecretValue + } + return vars, nil + } + values, e := c.sdk.Secrets().List(sdk.ListSecretsOptions{ProjectID: l.ProjectID, Environment: env, SecretPath: folder, Recursive: false, ExpandSecretReferences: false}) + if e != nil { + return nil, mapAPIError(e) + } + for _, v := range values { + vars[v.SecretKey] = v.SecretValue + } + return vars, nil +} + +func (c *Client) retrieveGlobalSecret(env, folder, key string) (*models.Secret, error) { + v, err := c.sdk.Secrets().Retrieve(sdk.RetrieveSecretOptions{ProjectID: c.cfg.ProjectID, Environment: env, SecretPath: folder, SecretKey: key, ExpandSecretReferences: false}) + if err != nil { + return nil, mapAPIError(err) + } + return &v, nil +} +func GlobalSummary(ctx context.Context) (*GlobalLocation, []RemoteEnvironment, error) { + _, location, _, err := globalClient(ctx, "") + if err != nil { + return nil, nil, err + } + p, err := Project(ctx, location.ProjectID) + if err != nil { + return nil, nil, err + } + return location, p.Environments, nil +} diff --git a/packages/cli/internal/adapters/env/infisical/global_test.go b/packages/cli/internal/adapters/env/infisical/global_test.go new file mode 100644 index 00000000..e2afdc18 --- /dev/null +++ b/packages/cli/internal/adapters/env/infisical/global_test.go @@ -0,0 +1,126 @@ +package infisical + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + "github.com/zalando/go-keyring" +) + +func TestGlobalLocationAndListingStayScoped(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var secretsCalled int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "bad token", 401) + return + } + switch { + case r.URL.Path == "/api/v1/workspace/project-1": + w.Write([]byte(`{"workspace":{"type":"secret-manager","id":"project-1","name":"Shared","orgId":"org-1","environments":[{"slug":"dev","name":"Development"},{"slug":"prod","name":"Production"}]}}`)) + case strings.Contains(r.URL.Path, "folders"): + if r.URL.Query().Get("path") != "/docker" { + t.Errorf("folder request: %s", r.URL) + } + w.Write([]byte(`{"folders":[{"id":"child","name":"nested"}]}`)) + case strings.Contains(r.URL.Path, "secrets"): + secretsCalled++ + if r.URL.Query().Get("recursive") == "true" { + t.Error("recursive read") + } + w.Write([]byte(`{"secrets":[{"secretKey":"PASSWORD","secretValue":"never-list-this","secretComment":"Registry password"}]}`)) + default: + t.Errorf("unexpected request %s", r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + store := func(user string) { + raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: upstream.URL, UserID: user, OrganizationID: "org-1", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) + if e := keyring.Set("one-cli.infisical", "session", string(raw)); e != nil { + t.Fatal(e) + } + } + store("user-1") + ctx := context.Background() + if _, e := BindGlobal(ctx, "project-1", "missing"); e == nil { + t.Fatal("bound missing env") + } + if _, e := BindGlobal(ctx, "project-1", "dev"); e != nil { + t.Fatal(e) + } + listing, e := ListGlobal(ctx, "prod", "/docker") + if e != nil { + t.Fatal(e) + } + raw, _ := json.Marshal(listing) + if strings.Contains(string(raw), "never-list-this") { + t.Fatal("list leaked value") + } + if len(listing.Folders) != 1 || listing.Folders[0] != "/docker/nested" { + t.Fatal(listing.Folders) + } + if _, e := GlobalValues(ctx, "missing", "/docker", nil); e == nil { + t.Fatal("invalid environment fell back") + } + if secretsCalled != 1 { + t.Fatal("fetched secrets for invalid environment") + } + store("user-2") + if _, e := ListGlobal(ctx, "prod", "/docker"); e == nil { + t.Fatal("account mismatch accepted") + } +} +func TestGlobalPathRejectsTraversal(t *testing.T) { + for _, p := range []string{"relative", "/a/../b", "/a/./b", "/a\\b", "/a\nb"} { + if _, e := ValidateGlobalPath(p); e == nil { + t.Errorf("accepted %q", p) + } + } +} + +func TestGlobalSelectedKeysDoNotReadOtherValuesOrExpandReferences(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + reads := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/workspace/shared": + w.Write([]byte(`{"workspace":{"type":"secret-manager","id":"shared","orgId":"org","environments":[{"slug":"prod"}]}}`)) + case "/api/v3/secrets/raw/AK": + reads++ + q := r.URL.Query() + if q.Get("environment") != "prod" || q.Get("secretPath") != "/oss" || q.Get("expandSecretReferences") == "true" || q.Get("include_imports") == "true" { + t.Errorf("scope widened: %s", r.URL) + } + w.Write([]byte(`{"secret":{"secretKey":"AK","secretValue":"selected-value"}}`)) + default: + t.Errorf("unexpected value read: %s", r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Fatal(err) + } + if _, err := BindGlobal(context.Background(), "shared", "prod"); err != nil { + t.Fatal(err) + } + values, err := GlobalValues(context.Background(), "prod", "/oss", []string{"AK"}) + if err != nil { + t.Fatal(err) + } + if reads != 1 || len(values) != 1 || values["AK"] != "selected-value" { + t.Fatalf("values=%v reads=%d", values, reads) + } +} diff --git a/packages/cli/internal/adapters/env/infisical/init.go b/packages/cli/internal/adapters/env/infisical/init.go index f43635bb..10bb32b4 100644 --- a/packages/cli/internal/adapters/env/infisical/init.go +++ b/packages/cli/internal/adapters/env/infisical/init.go @@ -14,6 +14,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" ) @@ -25,22 +26,14 @@ import ( // project), and --project-name overrides the desired name when // auto-creating. // -// Scope split (post-profile refactor): -// - This path writes WORKSPACE-level fields to manifest.domains.env.config -// and manifest.environments (projectId, projectName, environments, -// defaultEnv, rootPath). -// - SiteURL + credentials are MACHINE-level — they come from a -// profile (`one configure add env/infisical --profile `), not from flags here. +// Authentication uses the single browser session stored in the system keyring. +// Only project metadata is persisted in the workspace manifest. type InitInput struct { ProjectID string ProjectName string Environments []string DefaultEnv string RootPath string - // ProfileName one-shot overrides the default env profile (for the - // network call that creates / verifies the project). Doesn't change - // machine default. - ProfileName string // SkipVerify lets `init` write the config without contacting Infisical // (useful for offline workflows / generation tooling). Default off: // the CLI's value is in catching configuration mistakes early. @@ -68,19 +61,19 @@ func (r *InitResult) RenderTTY(w io.Writer) { if r == nil { return } - fmt.Fprintln(w, "✓ Secrets configuration written") + fmt.Fprintln(w, i18n.T("infisical.init.success")) if r.Created { - fmt.Fprintf(w, " Project: %s (%s) — created\n", r.ProjectName, r.ProjectID) + fmt.Fprintf(w, i18n.T("infisical.init.project_created"), r.ProjectName, r.ProjectID) } else if r.ProjectName != "" { - fmt.Fprintf(w, " Project: %s (%s)\n", r.ProjectName, r.ProjectID) + fmt.Fprintf(w, i18n.T("infisical.init.project_named"), r.ProjectName, r.ProjectID) } else { - fmt.Fprintf(w, " Project: %s\n", r.ProjectID) + fmt.Fprintf(w, i18n.T("infisical.init.project"), r.ProjectID) } - fmt.Fprintf(w, " Environments: %s (default: %s)\n", + fmt.Fprintf(w, i18n.T("infisical.init.environments"), strings.Join(r.Environments, ", "), r.DefaultEnv) - fmt.Fprintf(w, " Root path: %s\n", r.RootPath) - fmt.Fprintf(w, " Auth: %s\n", r.AuthStatus) - fmt.Fprintf(w, " Written to: %s\n", r.WrittenTo) + 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 @@ -106,7 +99,7 @@ const maxCreateProjectRetries = 5 func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, error) { if !workspace.HasManifest(projectRoot) { return nil, cliErrors.New(cliErrors.NOT_ONE_PROJECT, - "未找到 one.manifest.json。请先在 One workspace 根目录执行。") + i18n.T("workspace.manifest_required")) } // Inherit existing manifest.environments.names / default when the // caller didn't pass --envs / --default-env. This keeps re-runs of @@ -152,18 +145,17 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e if cfg.ProjectID == "" { if in.SkipVerify { return nil, cliErrors.New(cliErrors.INFISICAL_NOT_CONFIGURED, - "--skip-verify 与自动创建项目互斥。请显式传 --project-id,或去掉 --skip-verify。") + i18n.T("infisical.init.verify_conflict")) } desiredName, err := resolveProjectName(projectRoot, cfg.ProjectName) if err != nil { return nil, err } - profileName, creds, siteURL, err := loadInitCreds(projectRoot, in.ProfileName) + creds, siteURL, err := sessionCredentials() if err != nil { return nil, err } cfg.SiteURL = siteURL - cfg.ProfileName = profileName client, err := NewClient(ctx, cfg, creds) if err != nil { return nil, err @@ -186,12 +178,11 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e } } else if !in.SkipVerify { // Branch 1 / Branch-2-rewrite: validate the explicit / cached id. - profileName, creds, siteURL, err := loadInitCreds(projectRoot, in.ProfileName) + creds, siteURL, err := sessionCredentials() if err != nil { return nil, err } cfg.SiteURL = siteURL - cfg.ProfileName = profileName client, err := NewClient(ctx, cfg, creds) if err != nil { return nil, err @@ -229,13 +220,6 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e }, nil } -// loadInitCreds is a thin alias around requireProfileCreds, kept so the -// two call sites in Init read locally rather than spelling out the -// shared helper name. Profile-only — env vars retired. -func loadInitCreds(projectRoot, profileFlag string) (string, *Credentials, string, error) { - return requireProfileCreds(projectRoot, profileFlag) -} - // resolveProjectName picks the Infisical project name when env init is // auto-creating. Precedence: explicit override → manifest.project.name → // package.json#name → workspace folder basename. The first non-empty value @@ -255,7 +239,7 @@ func resolveProjectName(projectRoot, override string) (string, error) { return base, nil } return "", cliErrors.New(cliErrors.INFISICAL_NOT_CONFIGURED, - "无法推断 Infisical 项目名(manifest.project.name / package.json#name 都为空)。请显式传 --project-name 或 --project-id。") + i18n.T("infisical.init.name_required")) } func readPackageJSONName(projectRoot string) string { @@ -317,7 +301,7 @@ func createWithRetry(client *Client, baseName string) (string, string, error) { return "", "", err } return "", "", cliErrors.New(cliErrors.INFISICAL_PROJECT_NAME_TAKEN, - fmt.Sprintf("Infisical 项目名 %q 反复冲突,已重试 %d 次。请显式传 --project-name 指定一个唯一名字。", + i18n.Tf("infisical.init.name_conflicts", baseName, maxCreateProjectRetries)) } diff --git a/packages/cli/internal/adapters/env/infisical/loader.go b/packages/cli/internal/adapters/env/infisical/loader.go index 69ea1baf..db3dd71f 100644 --- a/packages/cli/internal/adapters/env/infisical/loader.go +++ b/packages/cli/internal/adapters/env/infisical/loader.go @@ -23,7 +23,7 @@ func (runLoader) Priority() secrets.Priority { return secrets.PriorityRemoteBack // Available is the gate for --from auto: Infisical must be both // configured in the workspace manifest AND have credentials available -// (env vars OR a default env profile). We avoid a network probe here +// (the single browser session). We avoid a network probe here // — it's a cheap pre-flight, not a healthcheck. If creds are stale, // the actual Load() call will surface the auth error. func (runLoader) Available(projectRoot string) bool { @@ -31,12 +31,12 @@ func (runLoader) Available(projectRoot string) bool { if err != nil || cfg == nil { return false } - // projectId is required even when creds come from a profile — + // projectId is required even when credentials come from a session — // project-level fields stay in the manifest. if strings.TrimSpace(cfg.ProjectID) == "" { return false } - return runCredsAvailable(projectRoot) + return sessionAvailable() } // Load delegates to FetchSecretsForSubproject — same code path the diff --git a/packages/cli/internal/adapters/env/infisical/paths.go b/packages/cli/internal/adapters/env/infisical/paths.go index 380197e7..a8d51c4d 100644 --- a/packages/cli/internal/adapters/env/infisical/paths.go +++ b/packages/cli/internal/adapters/env/infisical/paths.go @@ -6,6 +6,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" ) @@ -19,7 +20,7 @@ func SanitizeEnvName(s string) (string, error) { v := strings.TrimSpace(s) if !envNameRE.MatchString(v) { return "", cliErrors.New(cliErrors.ENV_INVALID_ENV_NAME, - "环境名称非法:"+s+"(必须匹配 ^[a-zA-Z0-9][a-zA-Z0-9-_]*$,例如 dev / staging / prod)") + i18n.Tf("env.environment_format", s)) } return v, nil } diff --git a/packages/cli/internal/adapters/env/infisical/pull.go b/packages/cli/internal/adapters/env/infisical/pull.go index 42ae07d1..bc61c499 100644 --- a/packages/cli/internal/adapters/env/infisical/pull.go +++ b/packages/cli/internal/adapters/env/infisical/pull.go @@ -128,7 +128,7 @@ func Pull(ctx context.Context, projectRoot string, in PullInput) (*PullResult, e } if conflict { return nil, cliErrors.New(cliErrors.ENV_PULL_CONFLICT, - "已有 .env 与 Infisical 拉取的密钥不一致:"+entry.EnvFilePath+"。如需覆盖请加 --force。"). + i18n.Tf("env.pull.file_conflict", entry.EnvFilePath)). WithContext(map[string]any{ "env_file_path": entry.EnvFilePath, "relative_dir": entry.RelativeDir, @@ -219,11 +219,10 @@ func buildPullTargets(projectRoot string, cfg *WorkspaceConfig, projectSelector if len(out) == 0 { if wantSub != "" { return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, - "找不到名字或路径匹配 "+wantSub+" 的项目。已声明: "+ - strings.Join(workspace.ProjectNames(m), ", ")) + i18n.Tf("workspace.project_selector_missing", wantSub, strings.Join(workspace.ProjectNames(m), ", "))) } return nil, cliErrors.New(cliErrors.MANIFEST_MISSING_OR_EMPTY, - "manifest 没有声明任何项目,且 workspace 根也没有 keys 可拉。先 `one add` 新建一个,或 `one env set` 在根级写入 keys。") + i18n.T("env.pull.empty")) } return out, nil } diff --git a/packages/cli/internal/adapters/env/infisical/run_profile.go b/packages/cli/internal/adapters/env/infisical/run_profile.go deleted file mode 100644 index ce690886..00000000 --- a/packages/cli/internal/adapters/env/infisical/run_profile.go +++ /dev/null @@ -1,113 +0,0 @@ -package infisical - -// run_profile.go is the single source of Infisical credentials for -// every internal caller (run-loader, init, verbs). Profiles are the -// only credential source — env vars (INFISICAL_UNIVERSAL_AUTH_*) were -// retired because two sources meant "which one wins?" confusion. -// -// Resolution chain (handled by profile.Resolve): -// -// 1. --profile one-shot flag override -// 2. project + environment binding ~/.config/one/profile-bindings.json -// 3. workspace + environment binding -// 4. legacy workspace/project binding -// 5. machine default ~/.config/one/config.json#env/infisical.default - -import ( - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -// resolveProfileCreds returns the resolved env profile's siteUrl + creds -// + the resolved profile name. Returns ("", nil, "", nil) — no error — -// when no profile is configured anywhere; caller decides whether the -// absence is fatal. Errors are surfaced only for "name was specified -// somewhere but doesn't exist" / corrupted-file conditions. -// -// profileFlag is the value of --profile (one-shot override); pass "" -// when the caller has no such flag. -func resolveProfileCreds(projectRoot, profileFlag string) (profileName string, creds *Credentials, siteURL string, err error) { - return resolveProfileCredsForContext(projectRoot, profileFlag, "", "") -} - -// resolveProfileCredsForContext is the runtime-aware variant used by `one -// run`. The workspace root, selected environment, and manifest project name -// address machine-local bindings; the manifest itself remains read-only. -func resolveProfileCredsForContext( - projectRoot, profileFlag, environment, projectName string, -) (profileName string, creds *Credentials, siteURL string, err error) { - workspaceID := "" - if m, mErr := workspace.ReadManifest(projectRoot); mErr == nil { - workspaceID = workspace.WorkspaceID(m) - } - resolved, rErr := profile.Resolve(profile.ResolveInput{ - Domain: profile.DomainEnv, - Backend: "infisical", - FlagOverride: profileFlag, - WorkspaceID: workspaceID, - WorkspaceRoot: projectRoot, - Environment: environment, - ProjectName: projectName, - }) - if rErr != nil { - // "no profile configured anywhere" is the cheap-path expected case - // — translate to ("", nil, "", nil) so callers don't need to type-check. - if cliErr, ok := rErr.(interface{ ErrorCode() string }); ok && - cliErr.ErrorCode() == "PROFILE_NONE_CONFIGURED" { - return "", nil, "", nil - } - return "", nil, "", rErr - } - if resolved.Profile.Backend != "infisical" || resolved.Profile.Infisical == nil || - resolved.Profile.Infisical.Credentials == nil { - // Resolved profile isn't infisical or has no creds — nothing for us. - return "", nil, "", nil - } - ip := resolved.Profile.Infisical - if strings.TrimSpace(ip.Credentials.ClientID) == "" || - strings.TrimSpace(ip.Credentials.ClientSecret) == "" { - return "", nil, "", nil - } - return resolved.Name, &Credentials{ - ClientID: ip.Credentials.ClientID, - ClientSecret: ip.Credentials.ClientSecret, - }, ip.SiteURL, nil -} - -// runCredsAvailable reports whether `one run` can authenticate to -// Infisical for projectRoot — i.e. there's a default env profile that -// supplies a credential pair. Cheap probe: no network. -func runCredsAvailable(projectRoot string) bool { - _, creds, _, _ := resolveProfileCreds(projectRoot, "") - return creds != nil -} - -// requireProfileCreds is the must-have variant: every caller that -// needs to talk to Infisical (init, run, verbs) goes through here. -// Returns INFISICAL_AUTH_MISSING with an actionable remediation when -// no profile is configured / the resolved profile lacks credentials. -func requireProfileCreds(projectRoot, profileFlag string) (string, *Credentials, string, error) { - return requireProfileCredsForContext(projectRoot, profileFlag, "", "") -} - -func requireProfileCredsForContext( - projectRoot, profileFlag, environment, projectName string, -) (string, *Credentials, string, error) { - name, creds, siteURL, err := resolveProfileCredsForContext( - projectRoot, profileFlag, environment, projectName, - ) - if err != nil { - return "", nil, "", err - } - if creds == nil { - return "", nil, "", cliErrors.New(cliErrors.INFISICAL_AUTH_MISSING, - "未找到可用的 env profile 凭据。先 `one configure add env/infisical --profile --client-id ... --client-secret ... --use`,或者 `one configure use env/infisical --profile ` 切到一个已配置的 profile。") - } - if siteURL == "" { - siteURL = DefaultSiteURL - } - return name, creds, siteURL, nil -} diff --git a/packages/cli/internal/adapters/env/infisical/run_profile_test.go b/packages/cli/internal/adapters/env/infisical/run_profile_test.go deleted file mode 100644 index 425f5d30..00000000 --- a/packages/cli/internal/adapters/env/infisical/run_profile_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package infisical - -import ( - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestResolveProfileCredsForContextUsesEnvironmentProjectBinding(t *testing.T) { - configRoot := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", configRoot) - t.Setenv("HOME", configRoot) - root := t.TempDir() - manifest := &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "workspace-id", Name: "demo"}, - Environments: &workspace.Environments{Names: []string{"dev", "prod"}, Default: "dev"}, - Projects: []workspace.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }}, - } - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - for _, name := range []string{"development", "production"} { - if _, err := profile.Upsert(profile.DomainEnv, "infisical", name, profile.Profile{ - Backend: "infisical", - Infisical: &profile.InfisicalProfile{ - SiteURL: "https://example.infisical.test", - Credentials: &profile.InfisicalCredentials{ - ClientID: "client-" + name, ClientSecret: "secret-" + name, - }, - }, - }, false); err != nil { - t.Fatal(err) - } - } - if err := profile.BindEnvironmentProfile( - "workspace-id", "demo", root, "", "prod", - profile.DomainEnv, "infisical", "development", - ); err != nil { - t.Fatal(err) - } - if err := profile.BindEnvironmentProfile( - "workspace-id", "demo", root, "web", "prod", - profile.DomainEnv, "infisical", "production", - ); err != nil { - t.Fatal(err) - } - - name, credentials, siteURL, err := resolveProfileCredsForContext( - root, "", "prod", "web", - ) - if err != nil { - t.Fatal(err) - } - if name != "production" || credentials == nil || - credentials.ClientID != "client-production" || - credentials.ClientSecret != "secret-production" || - siteURL != "https://example.infisical.test" { - t.Fatalf("resolved = name=%q credentials=%#v siteURL=%q", name, credentials, siteURL) - } -} - -func TestManifestProjectNameUsesStableManifestName(t *testing.T) { - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Projects: []workspace.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }}, - }); err != nil { - t.Fatal(err) - } - if got := manifestProjectName(root, "apps/web"); got != "web" { - t.Fatalf("manifestProjectName() = %q, want web", got) - } - if got := manifestProjectName(root, "apps/unknown"); got != "" { - t.Fatalf("unknown manifestProjectName() = %q", got) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/session.go b/packages/cli/internal/adapters/env/infisical/session.go new file mode 100644 index 00000000..ada18996 --- /dev/null +++ b/packages/cli/internal/adapters/env/infisical/session.go @@ -0,0 +1,12 @@ +package infisical + +import session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + +func sessionCredentials() (*Credentials, string, error) { + current, err := session.Require() + if err != nil { + return nil, "", err + } + return &Credentials{AccessToken: current.Token}, current.SiteURL, nil +} +func sessionAvailable() bool { _, err := session.Require(); return err == nil } diff --git a/packages/cli/internal/adapters/env/infisical/shared_location.go b/packages/cli/internal/adapters/env/infisical/shared_location.go new file mode 100644 index 00000000..4983e8ac --- /dev/null +++ b/packages/cli/internal/adapters/env/infisical/shared_location.go @@ -0,0 +1,112 @@ +package infisical + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/gofrs/flock" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" +) + +const DefaultSharedProject = "shared-credentials" +const DefaultSharedEnvironment = "dev" + +// CreateRemoteProject creates only Secret Manager projects. It does not change +// the saved location; the user chooses an environment before binding it. +func CreateRemoteProject(ctx context.Context, name string) (*RemoteProject, error) { + s, err := session.Require() + if err != nil { + return nil, err + } + return createProjectFor(ctx, s, name) +} + +func createProjectFor(ctx context.Context, s *session.Session, name string) (*RemoteProject, error) { + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > 64 || strings.ContainsFunc(name, unicode.IsControl) { + return nil, i18n.Errorf("infisical.project_name_invalid") + } + if s.OrganizationID == "" { + return nil, i18n.Errorf("infisical.organization_required") + } + c, err := NewClient(ctx, &WorkspaceConfig{SiteURL: s.SiteURL}, &Credentials{AccessToken: s.Token}) + if err != nil { + return nil, err + } + id, _, err := c.CreateProjectContext(ctx, name) + if err != nil { + return nil, err + } + return projectFor(ctx, s, id) +} + +// EnsureDefaultGlobal is an explicit mutation, never a side effect of GET. +// Reuse the named project after interrupted setup and preserve any saved location. +func EnsureDefaultGlobal(ctx context.Context) (*GlobalLocation, error) { + return withLocationLock(ctx, func() (*GlobalLocation, error) { + s, err := session.Require() + if err != nil { + return nil, err + } + location, err := LoadGlobalLocation() + if err != nil { + return nil, err + } + if location != nil { + if location.SiteURL != s.SiteURL || location.UserID != s.UserID || (s.OrganizationID != "" && location.OrganizationID != s.OrganizationID) { + return nil, i18n.Errorf("global.existing_location_mismatch") + } + return bindGlobalFor(ctx, s, location.ProjectID, location.DefaultEnvironment) + } + if s.OrganizationID == "" { + return nil, i18n.Errorf("infisical.organization_required") + } + projects, err := projectsFor(ctx, s) + if err != nil { + return nil, err + } + var selected *RemoteProject + for _, project := range projects { + if project.Name == DefaultSharedProject { + if selected != nil { + return nil, i18n.Errorf("global.duplicate_projects") + } + selected = &project + } + } + if selected == nil { + selected, err = createProjectFor(ctx, s, DefaultSharedProject) + if err != nil { + return nil, err + } + } + return bindGlobalFor(ctx, s, selected.ID, DefaultSharedEnvironment) + }) +} + +// Serialize setup and binding across Dashboard instances on this machine. +func withLocationLock(ctx context.Context, fn func() (*GlobalLocation, error)) (*GlobalLocation, error) { + p, err := session.ConfigPath("global-env.lock") + if err != nil { + return nil, err + } + if err = os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return nil, err + } + lock := flock.New(p) + ok, err := lock.TryLockContext(ctx, 50*time.Millisecond) + if err != nil { + return nil, err + } + if !ok { + return nil, i18n.Errorf("global.configuration_busy") + } + defer lock.Unlock() + return fn() +} diff --git a/packages/cli/internal/adapters/env/infisical/shared_location_test.go b/packages/cli/internal/adapters/env/infisical/shared_location_test.go new file mode 100644 index 00000000..79424442 --- /dev/null +++ b/packages/cli/internal/adapters/env/infisical/shared_location_test.go @@ -0,0 +1,191 @@ +package infisical + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + "github.com/zalando/go-keyring" +) + +func sharedTestSession(t *testing.T, site, user string) { + t.Helper() + raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: site, UserID: user, OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Fatal(err) + } +} + +func TestDefaultSharedLocationConcurrentSetupAndPreservation(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var mu sync.Mutex + creates := 0 + project := RemoteProject{ID: "shared", Name: DefaultSharedProject, Type: "secret-manager", OrganizationID: "org", Environments: []RemoteEnvironment{{Slug: "dev"}}} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/workspace": + json.NewEncoder(w).Encode(map[string]any{"workspaces": []RemoteProject{}}) + case "/api/v2/workspace": + creates++ + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["projectName"] != DefaultSharedProject || body["type"] != "secret-manager" || r.Method != "POST" || r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("invalid project creation: %v", body) + } + json.NewEncoder(w).Encode(map[string]any{"project": project}) + case "/api/v1/workspace/shared": + json.NewEncoder(w).Encode(map[string]any{"workspace": project}) + case "/api/v1/workspace/custom": + json.NewEncoder(w).Encode(map[string]any{"workspace": RemoteProject{ID: "custom", Name: "Custom", Type: "secret-manager", OrganizationID: "org", Environments: []RemoteEnvironment{{Slug: "prod"}}}}) + default: + t.Errorf("unexpected request: %s", r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + sharedTestSession(t, upstream.URL, "user") + ctx := context.Background() + results := make(chan error, 4) + for range 4 { + go func() { + location, err := EnsureDefaultGlobal(ctx) + if err == nil && (location.ProjectID != "shared" || location.DefaultEnvironment != "dev") { + t.Errorf("unexpected location: %+v", location) + } + results <- err + }() + } + for range 4 { + if err := <-results; err != nil { + t.Fatal(err) + } + } + if creates != 1 { + t.Fatalf("created %d projects", creates) + } + if _, err := BindGlobal(ctx, "custom", "prod"); err != nil { + t.Fatal(err) + } + location, err := EnsureDefaultGlobal(ctx) + if err != nil || location.ProjectID != "custom" || location.DefaultEnvironment != "prod" { + t.Fatalf("existing location overwritten: %+v %v", location, err) + } + sharedTestSession(t, upstream.URL, "different-user") + if _, err := EnsureDefaultGlobal(ctx); err == nil { + t.Fatal("accepted another account's location") + } + location, _ = LoadGlobalLocation() + if location.UserID != "user" { + t.Fatal("overwrote location after account change") + } +} + +func TestSharedProjectFilteringAndDefaultReuse(t *testing.T) { + for _, env := range []string{"dev", "prod"} { + t.Run(env, func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + project := RemoteProject{ID: "existing", Name: DefaultSharedProject, Type: "secret-manager", OrganizationID: "org", Environments: []RemoteEnvironment{{Slug: env}}} + cert := RemoteProject{ID: "cert", Name: "Certificates", Type: "cert-manager", OrganizationID: "org"} + foreign := project + foreign.ID, foreign.OrganizationID = "foreign", "other-org" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/workspace": + json.NewEncoder(w).Encode(map[string]any{"workspaces": []RemoteProject{cert, foreign, project}}) + case "/api/v1/workspace/existing": + json.NewEncoder(w).Encode(map[string]any{"workspace": project}) + case "/api/v1/workspace/cert": + json.NewEncoder(w).Encode(map[string]any{"workspace": cert}) + default: + t.Errorf("must not create or read secrets: %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + sharedTestSession(t, upstream.URL, "user") + ctx := context.Background() + projects, err := Projects(ctx) + if err != nil || len(projects) != 1 || projects[0].ID != "existing" { + t.Fatalf("wrong project filter: %+v %v", projects, err) + } + if _, err := BindGlobal(ctx, "cert", "dev"); err == nil { + t.Fatal("bound certificate project") + } + location, err := EnsureDefaultGlobal(ctx) + if env == "dev" { + if err != nil || location.ProjectID != "existing" { + t.Fatalf("did not reuse project: %+v %v", location, err) + } + } else { + if err == nil { + t.Fatal("silently switched default environment") + } + if location, _ = LoadGlobalLocation(); location != nil { + t.Fatal("saved incomplete setup") + } + } + }) + } +} + +func TestCreateSharedProjectValidatesAndDoesNotBind(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + creates := 0 + forbidden := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v2/workspace": + creates++ + if forbidden { + http.Error(w, "private-upstream-information", 403) + return + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["projectName"] != "Team" || body["type"] != "secret-manager" { + t.Errorf("invalid body %v", body) + } + w.Write([]byte(`{"project":{"id":"new","name":"Team"}}`)) + case "/api/v1/workspace/new": + w.Write([]byte(`{"workspace":{"id":"new","name":"Team","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`)) + default: + t.Errorf("unexpected request: %s", r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + sharedTestSession(t, upstream.URL, "user") + for _, name := range []string{"", " ", "a\nb", strings.Repeat("a", 65)} { + if _, err := CreateRemoteProject(context.Background(), name); err == nil { + t.Errorf("accepted invalid name %q", name) + } + } + if creates != 0 { + t.Fatal("invalid name reached upstream") + } + project, err := CreateRemoteProject(context.Background(), " Team ") + if err != nil || project.ID != "new" || len(project.Environments) != 1 { + t.Fatalf("invalid created project: %+v %v", project, err) + } + if location, _ := LoadGlobalLocation(); location != nil { + t.Fatal("creating a project changed saved location") + } + forbidden = true + if _, err := CreateRemoteProject(context.Background(), "Team"); err == nil || strings.Contains(err.Error(), "private-upstream-information") { + t.Fatalf("permission failure mishandled: %v", err) + } +} diff --git a/packages/cli/internal/adapters/runtime/mise/download.go b/packages/cli/internal/adapters/runtime/mise/download.go index 66204f77..1ddc03e0 100644 --- a/packages/cli/internal/adapters/runtime/mise/download.go +++ b/packages/cli/internal/adapters/runtime/mise/download.go @@ -11,6 +11,7 @@ import ( "time" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) const maxDownloadAttempts = 4 @@ -29,10 +30,10 @@ func defaultDownloader() downloader { Timeout: 2 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error { if req.URL.Scheme != "https" { - return fmt.Errorf("mise download cannot redirect to non-HTTPS URL") + return i18n.Errorf("mise.download.https_required") } if len(via) >= 10 { - return fmt.Errorf("mise download exceeded redirect limit") + return i18n.Errorf("mise.download.redirect_limit") } return nil }, @@ -58,7 +59,7 @@ func (d downloader) fetch(ctx context.Context, dir string, a releaseAsset) (stri return "", ctx.Err() } if !retry || attempt == maxDownloadAttempts { - return "", fmt.Errorf("mise download failed after %d attempt(s): %w", attempt, err) + return "", i18n.Errorf("mise.download.failed", attempt, err) } timer := time.NewTimer(d.backoff << (attempt - 1)) select { @@ -83,10 +84,10 @@ func (d downloader) attempt(ctx context.Context, dir string, a releaseAsset) (pa defer res.Body.Close() if res.StatusCode != http.StatusOK { retry = res.StatusCode == http.StatusRequestTimeout || res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 && res.StatusCode < 600 - return "", retry, fmt.Errorf("official mise release returned HTTP %d", res.StatusCode) + return "", retry, i18n.Errorf("mise.download.http_status", res.StatusCode) } if res.ContentLength > maxArchiveSize { - return "", false, fmt.Errorf("mise archive exceeds size limit") + return "", false, i18n.Errorf("mise.download.archive_too_large") } f, err := os.CreateTemp(dir, ".fetch-*") if err != nil { @@ -109,10 +110,10 @@ func (d downloader) attempt(ctx context.Context, dir string, a releaseAsset) (pa return "", false, closeErr } if n > maxArchiveSize { - return "", false, fmt.Errorf("mise archive exceeds size limit") + return "", false, i18n.Errorf("mise.download.archive_too_large") } if fmt.Sprintf("%x", hash.Sum(nil)) != a.ArchiveSHA256 { - return "", false, fmt.Errorf("mise archive SHA256 mismatch: %s", filepath.Base(a.Filename())) + return "", false, i18n.Errorf("mise.download.checksum", filepath.Base(a.Filename())) } keep = true return f.Name(), false, nil diff --git a/packages/cli/internal/adapters/runtime/mise/install.go b/packages/cli/internal/adapters/runtime/mise/install.go index fcf49074..34582306 100644 --- a/packages/cli/internal/adapters/runtime/mise/install.go +++ b/packages/cli/internal/adapters/runtime/mise/install.go @@ -15,7 +15,9 @@ import ( "time" "github.com/gofrs/flock" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) const maxArchiveSize = 256 << 20 @@ -51,7 +53,7 @@ func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { return "", err } if !locked { - return "", fmt.Errorf("could not lock mise runtime installation") + return "", i18n.Errorf("mise.install.lock_failed") } defer lock.Unlock() // Another process (for example the other half of `one dev`) may have @@ -63,7 +65,7 @@ func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { legacy := filepath.Join(i.legacyRoot, managedVersion, a.Platform, a.BinaryName()) if verifyExecutable(legacy, a.BinarySHA256) == nil { if i.out != nil { - fmt.Fprintf(i.out, "[one] Migrating verified mise %s to %s.\n", managedVersion, dir) + fmt.Fprintf(i.out, i18n.T("mise.install.migrating"), managedVersion, dir) } return target, publishBinary(ctx, target, a.BinarySHA256, func(dest io.Writer) error { f, err := os.Open(legacy) @@ -76,7 +78,7 @@ func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { } } if i.out != nil { - fmt.Fprintf(i.out, "[one] Downloading official mise %s (%s) into %s.\n", managedVersion, a.Platform, dir) + fmt.Fprintf(i.out, i18n.T("mise.install.downloading"), managedVersion, a.Platform, dir) } archive, err := i.downloader.fetch(ctx, dir, a) if err != nil { @@ -107,7 +109,7 @@ func publishBinary(ctx context.Context, target, digest string, write func(io.Wri return closeErr } if err := verifyFile(binary.Name(), digest, maxBinarySize); err != nil { - return fmt.Errorf("mise executable verification failed: %w", err) + return i18n.Errorf("mise.install.verification", err) } if err := ctx.Err(); err != nil { return err @@ -130,7 +132,7 @@ func verifyExecutable(path, expected string) error { return err } if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { - return fmt.Errorf("cached mise is not executable") + return i18n.Errorf("mise.install.not_executable") } return nil } @@ -141,7 +143,7 @@ func verifyFile(path, expected string, limit int64) error { return err } if !info.Mode().IsRegular() || info.Size() > limit { - return fmt.Errorf("invalid cached file: %s", path) + return i18n.Errorf("mise.install.invalid_cache", path) } f, err := os.Open(path) if err != nil { @@ -153,7 +155,7 @@ func verifyFile(path, expected string, limit int64) error { return err } if fmt.Sprintf("%x", hash.Sum(nil)) != expected { - return fmt.Errorf("SHA256 mismatch for %s", filepath.Base(path)) + return i18n.Errorf("mise.install.checksum", filepath.Base(path)) } return nil } @@ -164,7 +166,7 @@ func copyLimited(dest io.Writer, source io.Reader, limit int64) error { return err } if n > limit { - return fmt.Errorf("mise archive or executable exceeds size limit") + return i18n.Errorf("mise.install.size_limit") } return nil } @@ -199,7 +201,7 @@ func extractBinary(ctx context.Context, archive string, a releaseAsset, dest io. continue } if entry.UncompressedSize64 > maxBinarySize { - return fmt.Errorf("mise executable exceeds size limit") + return i18n.Errorf("mise.install.executable_size_limit") } r, err := entry.Open() if err != nil { @@ -228,13 +230,13 @@ func extractBinary(ctx context.Context, archive string, a releaseAsset, dest io. } if match(entry.Name) && entry.Typeflag == tar.TypeReg { if entry.Size > maxBinarySize { - return fmt.Errorf("mise executable exceeds size limit") + return i18n.Errorf("mise.install.executable_size_limit") } return copyLimited(dest, tr, maxBinarySize) } } } - return fmt.Errorf("mise archive does not contain the expected executable") + return i18n.Errorf("mise.install.missing_executable") } // Observe cancellation while inflating large release binaries. diff --git a/packages/cli/internal/adapters/runtime/mise/install_test.go b/packages/cli/internal/adapters/runtime/mise/install_test.go index 376f8aa3..57fd0401 100644 --- a/packages/cli/internal/adapters/runtime/mise/install_test.go +++ b/packages/cli/internal/adapters/runtime/mise/install_test.go @@ -10,7 +10,6 @@ import ( "encoding/hex" "errors" "fmt" - "github.com/gofrs/flock" "io" "os" "path/filepath" @@ -18,6 +17,8 @@ import ( "sync" "testing" "time" + + "github.com/gofrs/flock" ) func archiveFixture(t *testing.T, format string, binary []byte) (releaseAsset, []byte) { diff --git a/packages/cli/internal/adapters/runtime/mise/mise.go b/packages/cli/internal/adapters/runtime/mise/mise.go index bab49a33..8ecee8e9 100644 --- a/packages/cli/internal/adapters/runtime/mise/mise.go +++ b/packages/cli/internal/adapters/runtime/mise/mise.go @@ -14,6 +14,7 @@ import ( "time" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" ) @@ -74,7 +75,7 @@ func (r binaryResolver) resolve(ctx context.Context, command runtimeport.Command return resolvedBinary{}, err } if err := verifyExecutable(canonicalPath(path), a.BinarySHA256); err != nil { - return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, "Explicit One-managed mise failed verification: "+err.Error()) + return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, i18n.Tf("mise.explicit_failed", err)) } } else if err := checkVersion(ctx, path, command); err != nil { return resolvedBinary{}, err @@ -90,7 +91,7 @@ func (r binaryResolver) resolve(ctx context.Context, command runtimeport.Command } else if ctx.Err() != nil { return resolvedBinary{}, ctx.Err() } else if r.out != nil { - fmt.Fprintf(r.out, "[one] Skipping unavailable or incompatible mise at %s: %v\n", path, err) + fmt.Fprintf(r.out, i18n.T("mise.skipping"), path, err) } } if err := ctx.Err(); err != nil { @@ -105,8 +106,8 @@ func (r binaryResolver) resolve(ctx context.Context, command runtimeport.Command } path, err := r.install(ctx, a) if err != nil { - return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, fmt.Sprintf("Could not prepare mise %s (%s) in %s: %v", managedVersion, a.Platform, r.paths.runtimeRoot(), err)).WithRemediation(output.Remediation{ - Action: "prepare-mise", Hint: "Check network/proxy access to GitHub Releases and permissions on the One runtime directory, then retry the same command. For offline use, install a compatible mise on PATH or set ONE_MISE_BINARY to its executable. ONE_RUNTIME=builtin uses existing tools for diagnostics.", + return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, i18n.Tf("mise.prepare_failed", managedVersion, a.Platform, r.paths.runtimeRoot(), err)).WithRemediation(output.Remediation{ + Action: "prepare-mise", Hint: i18n.T("mise.prepare_hint"), }) } // The executable digest pins the managed version; no online probe is needed. @@ -157,7 +158,7 @@ func systemCandidates(env []string) []string { func checkVersion(ctx context.Context, path string, command runtimeport.Command) error { if _, err := os.Stat(path); err != nil { - return cliErrors.New(cliErrors.MISE_NOT_FOUND, "Cannot access the mise executable: "+err.Error()) + return cliErrors.New(cliErrors.MISE_NOT_FOUND, i18n.Tf("mise.access_failed", err)) } probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -169,10 +170,10 @@ func checkVersion(ctx context.Context, path string, command runtimeport.Command) return ctx.Err() } if err != nil { - return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, "Could not read the mise version: "+err.Error()) + return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, i18n.Tf("mise.version_failed", err)) } if !supportedVersion(string(version)) { - return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, fmt.Sprintf("mise %s or newer is required.", runtimeport.MinimumMiseVersion)) + return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, i18n.Tf("mise.version_required", runtimeport.MinimumMiseVersion)) } return nil } diff --git a/packages/cli/internal/adapters/runtime/mise/miserelease/release.go b/packages/cli/internal/adapters/runtime/mise/miserelease/release.go index 94a89630..049774fe 100644 --- a/packages/cli/internal/adapters/runtime/mise/miserelease/release.go +++ b/packages/cli/internal/adapters/runtime/mise/miserelease/release.go @@ -1,7 +1,7 @@ package miserelease import ( - "fmt" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // Pin the release and both the archive and extracted binary digests. These @@ -31,7 +31,7 @@ func ForPlatform(goos, goarch string) (Asset, error) { case "windows/amd64": a = Asset{"windows-x64", "zip", "caa1ca158f04d91f42dc2cd99bb1f69f6b5bfa0d0772d485150120aad9778685", "80552c6a4a03849707cb6154186a22795516388d06bf0760b4418729a42efe43"} default: - return a, fmt.Errorf("managed mise download is unsupported on %s/%s; install a compatible mise on PATH or set ONE_MISE_BINARY", goos, goarch) + return a, i18n.Errorf("mise.platform_unsupported", goos, goarch) } return a, nil } diff --git a/packages/cli/internal/adapters/runtime/mise/paths.go b/packages/cli/internal/adapters/runtime/mise/paths.go index 22ee98d2..eed0ffdd 100644 --- a/packages/cli/internal/adapters/runtime/mise/paths.go +++ b/packages/cli/internal/adapters/runtime/mise/paths.go @@ -1,12 +1,12 @@ package mise import ( - "fmt" "os" "path/filepath" "runtime" "strings" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/userdirs" ) @@ -42,7 +42,7 @@ func defaultPaths() (runtimePaths, error) { } if !filepath.IsAbs(root) { if firstErr == nil { - firstErr = fmt.Errorf("%s must resolve to an absolute path", entry.key) + firstErr = i18n.Errorf("path.absolute_required", entry.key) } continue } diff --git a/packages/cli/internal/adapters/toolchain/go.go b/packages/cli/internal/adapters/toolchain/go.go index 48532100..6fbc63b9 100644 --- a/packages/cli/internal/adapters/toolchain/go.go +++ b/packages/cli/internal/adapters/toolchain/go.go @@ -26,36 +26,6 @@ func (goAdapter) PackageManagerForManifest(_ toolchain.PackageManager) toolchain return "" } -func (goAdapter) ResolveRuntime(_ toolchain.PlanInput) toolchain.RuntimeResolution { - // Hard-coded values mirror the TS adapter — every Go template builds - // to /app/server and exposes 3000. - return toolchain.RuntimeResolution{ - RunCommand: "/app/server", - ContainerPort: 3000, - } -} - -func (goAdapter) RenderDockerfile(in toolchain.DockerfileInput) string { - // Go template Dockerfiles are static; only the EXPOSE port varies. - return `# Generated by One CLI -FROM golang:1.27-alpine AS builder - -WORKDIR /workspace -COPY . . -RUN go mod tidy -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server - -FROM alpine:3.24 - -RUN apk add --no-cache ca-certificates -WORKDIR /app -COPY --from=builder /out/server /app/server - -EXPOSE ` + intToString(in.Runtime.ContainerPort) + ` -CMD ["/app/server"] -` -} - func (goAdapter) RenderWorkflow(in toolchain.WorkflowInput) string { lines := workflowHeader(in.ProjectName, in.RelativeDir, in.WorkflowFilePath) lines = append(lines, diff --git a/packages/cli/internal/adapters/toolchain/node.go b/packages/cli/internal/adapters/toolchain/node.go index 1f61d0b9..dcae26ef 100644 --- a/packages/cli/internal/adapters/toolchain/node.go +++ b/packages/cli/internal/adapters/toolchain/node.go @@ -4,7 +4,6 @@ package adapters import ( - "fmt" "strings" "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" @@ -36,62 +35,6 @@ func (nodeAdapter) PackageManagerForManifest(pm toolchain.PackageManager) toolch return pm } -func (nodeAdapter) ResolveRuntime(in toolchain.PlanInput) toolchain.RuntimeResolution { - pm := resolvePackageManager(in.PackageManager) - scripts := in.Scripts - if scripts == nil { - scripts = map[string]string{} - } - templateID := in.TemplateID - if templateID == "" { - templateID = "custom" - } - candidate, port, ok := pickRuntimeCandidate(scripts, templateID) - if !ok { - return toolchain.RuntimeResolution{ - RunCommand: `node -e "console.error('No runnable script found. Update Dockerfile CMD manually.'); process.exit(1)"`, - ContainerPort: defaultRuntimePreset.ContainerPort, - } - } - base := resolveRunScriptCommand(pm, candidate.Script, "") - return toolchain.RuntimeResolution{ - RunCommand: base + candidate.Args, - ContainerPort: port, - } -} - -func (nodeAdapter) RenderDockerfile(in toolchain.DockerfileInput) string { - pm := resolvePackageManager(in.PackageManager) - buildPolicy := "" - if pm == toolchain.PMpnpm { - // Containers install a single project without the parent workspace's - // pnpm configuration. Declare the bundled templates' native build steps. - buildPolicy = `RUN printf '%s\n' 'allowBuilds:' \ - ' "@parcel/watcher": true' \ - ' "@scarf/scarf": false' \ - ' "@swc/core": true' \ - ' esbuild: true' \ - ' unrs-resolver: true' > pnpm-workspace.yaml - -` - } - return fmt.Sprintf(`# Generated by One CLI -FROM node:24-alpine - -WORKDIR /workspace -RUN corepack enable - -COPY package.json ./ -COPY pnpm-lock.yaml* package-lock.json* yarn.lock* ./ -%sRUN %s - -COPY . . - -EXPOSE %d -CMD ["sh", "-c", "%s"] -`, buildPolicy, resolveNodeInstallCommand(pm, false), in.Runtime.ContainerPort, escapeForDoubleQuotedValue(in.Runtime.RunCommand)) -} - func (nodeAdapter) RenderWorkflow(in toolchain.WorkflowInput) string { pm := resolvePackageManager(in.PackageManager) lockfile := resolveLockfileByPM(pm) diff --git a/packages/cli/internal/adapters/toolchain/runtime.go b/packages/cli/internal/adapters/toolchain/runtime.go index c1ec5151..266b02be 100644 --- a/packages/cli/internal/adapters/toolchain/runtime.go +++ b/packages/cli/internal/adapters/toolchain/runtime.go @@ -7,73 +7,6 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" ) -// runtimeCandidate is one candidate npm/pnpm script the runtime resolver -// will probe in priority order. args is appended verbatim after the script -// name (e.g. " -- --host 0.0.0.0"). -type runtimeCandidate struct { - Script string - Args string -} - -type runtimePreset struct { - ContainerPort int - Candidates []runtimeCandidate -} - -// defaultRuntimePreset is the fallback when a templateId has no entry in -// templateRuntimePresets. Mirrors DEFAULT_RUNTIME_PRESET in TS. -var defaultRuntimePreset = runtimePreset{ - ContainerPort: 3000, - Candidates: []runtimeCandidate{ - {Script: "dev"}, - {Script: "start:dev"}, - {Script: "start"}, - {Script: "preview"}, - {Script: "web"}, - }, -} - -// templateRuntimePresets is the per-template port + run-command policy. -// Order matters: the first script that's present in package.json wins. -var templateRuntimePresets = map[string]runtimePreset{ - "nestjs-api": { - ContainerPort: 3000, - Candidates: []runtimeCandidate{{Script: "start:dev"}, {Script: "start"}}, - }, - "nextjs-app": { - ContainerPort: 3000, - Candidates: []runtimeCandidate{ - {Script: "dev", Args: " -- --hostname 0.0.0.0 --port 3000"}, - {Script: "start"}, - }, - }, - "react-spa": { - ContainerPort: 5173, - Candidates: []runtimeCandidate{ - {Script: "dev", Args: " -- --host 0.0.0.0 --port 5173"}, - {Script: "preview", Args: " -- --host 0.0.0.0 --port 5173"}, - }, - }, - "astro-site": { - ContainerPort: 4321, - Candidates: []runtimeCandidate{ - {Script: "dev", Args: " -- --host 0.0.0.0 --port 4321"}, - {Script: "preview", Args: " -- --host 0.0.0.0 --port 4321"}, - }, - }, - "starlight-docs": { - ContainerPort: 4321, - Candidates: []runtimeCandidate{ - {Script: "dev", Args: " -- --host 0.0.0.0 --port 4321"}, - {Script: "preview", Args: " -- --host 0.0.0.0 --port 4321"}, - }, - }, - "expo-mobile": { - ContainerPort: 19006, - Candidates: []runtimeCandidate{{Script: "web"}, {Script: "start"}}, - }, -} - func resolvePackageManager(pm toolchain.PackageManager) toolchain.PackageManager { if pm == "" { return toolchain.PMpnpm @@ -184,31 +117,3 @@ func resolveNodeCiCommands(scripts map[string]string, pm toolchain.PackageManage } return cmds } - -// pickRuntimeCandidate returns the first candidate whose script is present -// in the subproject's package.json. The container port is taken from the -// preset associated with the templateID, regardless of which candidate -// matched (matches the TS behaviour). -func pickRuntimeCandidate(scripts map[string]string, templateID string) (runtimeCandidate, int, bool) { - preset, ok := templateRuntimePresets[templateID] - if !ok { - preset = defaultRuntimePreset - } - for _, c := range preset.Candidates { - if _, present := scripts[c.Script]; present { - return c, preset.ContainerPort, true - } - } - for _, c := range defaultRuntimePreset.Candidates { - if _, present := scripts[c.Script]; present { - return c, preset.ContainerPort, true - } - } - return runtimeCandidate{}, 0, false -} - -func escapeForDoubleQuotedValue(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - return s -} diff --git a/packages/cli/internal/adapters/toolchain/runtime_test.go b/packages/cli/internal/adapters/toolchain/runtime_test.go index 8f1bb559..148603c8 100644 --- a/packages/cli/internal/adapters/toolchain/runtime_test.go +++ b/packages/cli/internal/adapters/toolchain/runtime_test.go @@ -191,95 +191,3 @@ func TestResolveNodeCiCommands(t *testing.T) { }) } } - -func TestPickRuntimeCandidate(t *testing.T) { - cases := []struct { - name string - scripts map[string]string - templateID string - wantScript string - wantPort int - wantOK bool - }{ - { - name: "react-spa with dev script → port 5173 + --host args", - scripts: map[string]string{"dev": "vite"}, - templateID: "react-spa", - wantScript: "dev", - wantPort: 5173, - wantOK: true, - }, - { - name: "nestjs-api with start:dev", - scripts: map[string]string{"start:dev": "nest start --watch"}, - templateID: "nestjs-api", - wantScript: "start:dev", - wantPort: 3000, - wantOK: true, - }, - { - name: "nestjs-api fallback to start when start:dev absent", - scripts: map[string]string{"start": "node dist"}, - templateID: "nestjs-api", - wantScript: "start", - wantPort: 3000, - wantOK: true, - }, - { - name: "unknown template falls back to defaultRuntimePreset", - scripts: map[string]string{"dev": "node ."}, - templateID: "unknown-template", - wantScript: "dev", - wantPort: 3000, - wantOK: true, - }, - { - name: "react-spa with no candidate scripts → fall through to defaultPreset (which also fails here)", - scripts: map[string]string{"build": "vite build"}, - templateID: "react-spa", - wantOK: false, - }, - { - name: "preset-port retained even when default-preset script wins", - scripts: map[string]string{"start": "node ."}, // not in react-spa preset; matches default - templateID: "react-spa", - wantScript: "start", - wantPort: 5173, // port is taken from preset, not defaultPreset - wantOK: true, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - cand, port, ok := pickRuntimeCandidate(tc.scripts, tc.templateID) - if ok != tc.wantOK { - t.Fatalf("ok: want %v, got %v", tc.wantOK, ok) - } - if !ok { - return - } - if cand.Script != tc.wantScript { - t.Errorf("script: want %q, got %q", tc.wantScript, cand.Script) - } - if port != tc.wantPort { - t.Errorf("port: want %d, got %d", tc.wantPort, port) - } - }) - } -} - -func TestEscapeForDoubleQuotedValue(t *testing.T) { - cases := []struct { - in, want string - }{ - {"plain", "plain"}, - {`with "quote"`, `with \"quote\"`}, - {`with \backslash`, `with \\backslash`}, - {`mixed "and"\here`, `mixed \"and\"\\here`}, - } - for _, tc := range cases { - got := escapeForDoubleQuotedValue(tc.in) - if got != tc.want { - t.Errorf("escape(%q) = %q, want %q", tc.in, got, tc.want) - } - } -} diff --git a/packages/cli/internal/application/ci/operations.go b/packages/cli/internal/application/ci/operations.go index 12974053..a462351a 100644 --- a/packages/cli/internal/application/ci/operations.go +++ b/packages/cli/internal/application/ci/operations.go @@ -2,7 +2,6 @@ package ci import ( "context" - "fmt" "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" @@ -158,7 +157,7 @@ func (s *Service) PlanDisable(ctx context.Context, selector string) (DisablePlan func (s *Service) Disable(plan DisablePlan, confirmed bool) (*ActionResult, error) { if plan.workspace.Manifest() == nil { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, "CI disable plan is required") + return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, i18n.T("ci.disable_plan_required")) } if !confirmed { enabledCount, err := s.enabledCount(plan.workspace.Root(), plan.projects) @@ -178,7 +177,7 @@ func (s *Service) Disable(plan DisablePlan, confirmed bool) (*ActionResult, erro if err != nil { return nil, cliErrors.New( cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("remove CI workflow for %s: %v", project.Name, err), + i18n.Tf("ci.remove_failed", project.Name, err), ).WithContext(map[string]any{ "project": project.Name, "workflow_path": relativeWorkflowPath(root, path), }) @@ -236,7 +235,7 @@ func disableConfirmationError(selector string, enabledCount int) error { func renderError(project, provider string, err error) error { return cliErrors.New( cliErrors.CI_RENDER_FAILED, - fmt.Sprintf("render CI workflow for %s: %v", project, err), + i18n.Tf("ci.render_failed", project, err), ).WithContext(map[string]any{"project": project, "provider": provider}) } diff --git a/packages/cli/internal/application/ci/service.go b/packages/cli/internal/application/ci/service.go index 734981d3..e5c91fce 100644 --- a/packages/cli/internal/application/ci/service.go +++ b/packages/cli/internal/application/ci/service.go @@ -5,7 +5,6 @@ package ci import ( "encoding/json" "errors" - "fmt" "io/fs" "os" "path/filepath" @@ -26,7 +25,7 @@ type Service struct { func NewService(providers *pkgci.Registry) (*Service, error) { if providers == nil || len(providers.Providers()) == 0 { - return nil, errors.New("application: CI providers are required") + return nil, errors.New(i18n.T("ci.providers_required")) } return &Service{providers: providers}, nil } @@ -97,7 +96,7 @@ func (s *Service) syncProject( if provider == nil { return syncResult{}, cliErrors.New( cliErrors.CI_PROVIDER_UNKNOWN, - fmt.Sprintf("unknown CI provider %q", providerID), + i18n.Tf("ci.provider_unknown", providerID), ) } tc := toolchain.Toolchain(project.Toolchain) @@ -160,7 +159,7 @@ func workflowExists(path string) (bool, error) { if info.IsDir() { return false, cliErrors.New( cliErrors.CI_RENDER_FAILED, - fmt.Sprintf("CI workflow path is a directory: %s", path), + i18n.Tf("ci.workflow_is_directory", path), ) } return true, nil diff --git a/packages/cli/internal/application/configure/profile_mask.go b/packages/cli/internal/application/configure/profile_mask.go deleted file mode 100644 index 304b84e5..00000000 --- a/packages/cli/internal/application/configure/profile_mask.go +++ /dev/null @@ -1,270 +0,0 @@ -package configure - -import ( - "encoding/json" - "fmt" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" -) - -const MaskedCredential = "********" - -// MaskConfig applies the HTTP disclosure policy declared by the Catalog: -// fields marked secret are hidden, while non-secret account identifiers stay -// visible. JSON rewriting is deliberate here—the Catalog paths are JSON paths, -// so masking validates the same shape consumed by the Dashboard. -func (s *ProfileService) MaskConfig(config profile.Config) (profile.Config, error) { - document, err := jsonObject(config) - if err != nil { - return profile.Config{}, fmt.Errorf("application: encode profile config for masking: %w", err) - } - for _, spec := range s.catalog.All() { - paths := profileFieldPaths(spec.Profile.Fields, func(field catalog.FieldSpec) bool { - return field.Type == catalog.FieldSecret - }) - if len(paths) == 0 { - continue - } - section, ok := objectValue(document[spec.Pair]) - if !ok { - continue - } - profiles, ok := objectValue(section["profiles"]) - if !ok { - continue - } - for _, value := range profiles { - payload, ok := objectValue(value) - if !ok { - continue - } - maskJSONPaths(payload, paths) - } - } - - raw, err := json.Marshal(document) - if err != nil { - return profile.Config{}, fmt.Errorf("application: encode masked profile config: %w", err) - } - var masked profile.Config - if err := json.Unmarshal(raw, &masked); err != nil { - return profile.Config{}, fmt.Errorf("application: decode masked profile config: %w", err) - } - return masked, nil -} - -// MaskProfile applies the stricter CLI `configure show` policy. Every field -// nested below credentials is hidden, including account identifiers. The set -// of paths still comes from the Catalog rather than from backend switches. -func (s *ProfileService) MaskProfile(value profile.Profile) (profile.Profile, error) { - result := value - seen := make(map[catalog.ProfileType]struct{}) - for _, spec := range s.catalog.All() { - profileType := spec.Profile.Type - if _, ok := seen[profileType]; ok { - continue - } - seen[profileType] = struct{}{} - payload, ok := profile.Payload(spec, result) - if !ok { - continue - } - paths := s.profileTypePaths(profileType, func(field catalog.FieldSpec) bool { - return strings.HasPrefix(field.Path, "credentials/") - }) - if len(paths) == 0 { - continue - } - document, err := jsonObject(payload) - if err != nil { - return profile.Profile{}, fmt.Errorf("application: encode %s profile for masking: %w", profileType, err) - } - maskJSONPaths(document, paths) - raw, err := json.Marshal(document) - if err != nil { - return profile.Profile{}, fmt.Errorf("application: encode masked %s profile: %w", profileType, err) - } - if err := profile.ReplacePayload(spec, &result, raw); err != nil { - return profile.Profile{}, fmt.Errorf("application: decode masked %s profile: %w", profileType, err) - } - } - return result, nil -} - -func (s *ProfileService) containsMaskedCredential( - spec catalog.BackendSpec, - value profile.Profile, -) (bool, error) { - payload, ok := profile.Payload(spec, value) - if !ok { - return false, nil - } - document, err := jsonObject(payload) - if err != nil { - return false, fmt.Errorf("application: encode %s profile: %w", spec.Pair, err) - } - for _, path := range profileFieldPaths(spec.Profile.Fields, func(field catalog.FieldSpec) bool { - return field.Type == catalog.FieldSecret - }) { - if current, ok := jsonPathValue(document, path); ok && current == MaskedCredential { - return true, nil - } - } - return false, nil -} - -func (s *ProfileService) preserveMaskedCredentials( - config *profile.Config, - spec catalog.BackendSpec, - name string, - value *profile.Profile, -) error { - if value == nil { - return nil - } - incoming, ok := profile.Payload(spec, *value) - if !ok { - return nil - } - existingProfile, ok := profile.LookupStored(config, spec, name) - if !ok { - return nil - } - existing, ok := profile.Payload(spec, existingProfile) - if !ok { - return nil - } - incomingDocument, err := jsonObject(incoming) - if err != nil { - return fmt.Errorf("application: encode incoming %s profile: %w", spec.Pair, err) - } - existingDocument, err := jsonObject(existing) - if err != nil { - return fmt.Errorf("application: encode existing %s profile: %w", spec.Pair, err) - } - changed := false - for _, path := range profileFieldPaths(spec.Profile.Fields, func(field catalog.FieldSpec) bool { - return field.Type == catalog.FieldSecret - }) { - current, exists := jsonPathValue(incomingDocument, path) - if !exists || current != MaskedCredential { - continue - } - stored, exists := jsonPathValue(existingDocument, path) - if !exists { - continue - } - if replaceJSONPath(incomingDocument, path, stored) { - changed = true - } - } - if !changed { - return nil - } - raw, err := json.Marshal(incomingDocument) - if err != nil { - return fmt.Errorf("application: encode preserved %s profile: %w", spec.Pair, err) - } - if err := profile.ReplacePayload(spec, value, raw); err != nil { - return fmt.Errorf("application: decode preserved %s profile: %w", spec.Pair, err) - } - return nil -} - -func (s *ProfileService) profileTypePaths( - profileType catalog.ProfileType, - include func(catalog.FieldSpec) bool, -) []string { - seen := map[string]struct{}{} - var paths []string - for _, spec := range s.catalog.All() { - if spec.Profile.Type != profileType { - continue - } - for _, path := range profileFieldPaths(spec.Profile.Fields, include) { - if _, ok := seen[path]; ok { - continue - } - seen[path] = struct{}{} - paths = append(paths, path) - } - } - return paths -} - -func profileFieldPaths( - fields []catalog.FieldSpec, - include func(catalog.FieldSpec) bool, -) []string { - paths := make([]string, 0, len(fields)) - for _, field := range fields { - if include(field) { - paths = append(paths, field.Path) - } - } - return paths -} - -func jsonObject(value any) (map[string]any, error) { - raw, err := json.Marshal(value) - if err != nil { - return nil, err - } - var document map[string]any - if err := json.Unmarshal(raw, &document); err != nil { - return nil, err - } - return document, nil -} - -func objectValue(value any) (map[string]any, bool) { - document, ok := value.(map[string]any) - return document, ok -} - -func maskJSONPaths(document map[string]any, paths []string) { - for _, path := range paths { - replaceJSONPath(document, path, MaskedCredential) - } -} - -func jsonPathValue(document map[string]any, path string) (any, bool) { - parts := strings.Split(path, "/") - current := document - for index, part := range parts { - value, ok := current[part] - if !ok { - return nil, false - } - if index == len(parts)-1 { - return value, true - } - current, ok = objectValue(value) - if !ok { - return nil, false - } - } - return nil, false -} - -func replaceJSONPath(document map[string]any, path string, replacement any) bool { - parts := strings.Split(path, "/") - current := document - for index, part := range parts { - if index == len(parts)-1 { - if _, ok := current[part]; !ok { - return false - } - current[part] = replacement - return true - } - next, ok := objectValue(current[part]) - if !ok { - return false - } - current = next - } - return false -} diff --git a/packages/cli/internal/application/configure/profile_service.go b/packages/cli/internal/application/configure/profile_service.go deleted file mode 100644 index 8dd4b6ff..00000000 --- a/packages/cli/internal/application/configure/profile_service.go +++ /dev/null @@ -1,376 +0,0 @@ -// Package application contains transport-neutral use cases. CLI commands and -// HTTP handlers translate inputs and outputs; they do not implement profile -// storage, backend dispatch, masking, or catalog validation themselves. -package configure - -import ( - "encoding/json" - "errors" - "fmt" - "sort" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -// ProfileRepository is the persistence port used by ProfileService. The local -// adapter keeps the existing v1 two-file storage contract; tests can inject an -// in-memory implementation without changing process-global environment state. -type ProfileRepository interface { - Load() (*profile.Config, *profile.CredentialsFile, error) - Upsert(profile.Domain, string, string, profile.Profile, bool) (bool, error) - Remove(profile.Domain, string, string) error - SetDefault(profile.Domain, string, string) error - BindWorkspaceProfile(string, string, string, string, profile.Domain, string, string) error - UnbindWorkspaceProfile(string, string, profile.Domain, string) error - BindEnvironmentProfile(string, string, string, string, string, profile.Domain, string, string) error - UnbindEnvironmentProfile(string, string, string, profile.Domain, string) error - EnvironmentProfileBinding(string, string, string, profile.Domain, string) (string, error) - Resolve(profile.ResolveInput) (*profile.Resolved, error) - ConfigPath() (string, error) - CredentialsPath() (string, error) -} - -// LocalProfileRepository adapts the compatibility profile package to the -// application port. The profile package remains the owner of on-disk v1 JSON. -type LocalProfileRepository struct{} - -func (LocalProfileRepository) Load() (*profile.Config, *profile.CredentialsFile, error) { - return profile.Load() -} - -func (LocalProfileRepository) Upsert( - domain profile.Domain, - backend, name string, - value profile.Profile, - setDefault bool, -) (bool, error) { - return profile.Upsert(domain, backend, name, value, setDefault) -} - -func (LocalProfileRepository) Remove(domain profile.Domain, backend, name string) error { - return profile.Remove(domain, backend, name) -} - -func (LocalProfileRepository) SetDefault(domain profile.Domain, backend, name string) error { - return profile.SetDefault(domain, backend, name) -} - -func (LocalProfileRepository) BindWorkspaceProfile( - workspaceID, workspaceName, root, projectName string, - domain profile.Domain, - backend, name string, -) error { - return profile.BindWorkspaceProfile( - workspaceID, workspaceName, root, projectName, domain, backend, name, - ) -} - -func (LocalProfileRepository) UnbindWorkspaceProfile( - workspaceID, projectName string, - domain profile.Domain, - backend string, -) error { - return profile.UnbindWorkspaceProfile(workspaceID, projectName, domain, backend) -} - -func (LocalProfileRepository) BindEnvironmentProfile( - workspaceID, workspaceName, root, projectName, environment string, - domain profile.Domain, - backend, name string, -) error { - return profile.BindEnvironmentProfile( - workspaceID, workspaceName, root, projectName, environment, domain, backend, name, - ) -} - -func (LocalProfileRepository) UnbindEnvironmentProfile( - root, projectName, environment string, - domain profile.Domain, - backend string, -) error { - return profile.UnbindEnvironmentProfile(root, projectName, environment, domain, backend) -} - -func (LocalProfileRepository) EnvironmentProfileBinding( - root, projectName, environment string, - domain profile.Domain, - backend string, -) (string, error) { - return profile.EnvironmentProfileBinding(root, projectName, environment, domain, backend) -} - -func (LocalProfileRepository) Resolve(input profile.ResolveInput) (*profile.Resolved, error) { - return profile.Resolve(input) -} - -func (LocalProfileRepository) ConfigPath() (string, error) { return profile.ConfigPath() } - -func (LocalProfileRepository) CredentialsPath() (string, error) { - return profile.CredentialsPath() -} - -// ProfileService is the single profile use-case boundary shared by Cobra and -// the local HTTP API. -type ProfileService struct { - catalog *catalog.Catalog - repository ProfileRepository -} - -func NewProfileService( - backendCatalog *catalog.Catalog, - repository ProfileRepository, -) (*ProfileService, error) { - if backendCatalog == nil { - return nil, errors.New("application: profile catalog is required") - } - if repository == nil { - return nil, errors.New("application: profile repository is required") - } - if err := profile.ValidateCatalog(backendCatalog); err != nil { - return nil, err - } - return &ProfileService{catalog: backendCatalog, repository: repository}, nil -} - -func (s *ProfileService) Load() (*profile.Config, error) { - config, _, err := s.repository.Load() - return config, err -} - -func (s *ProfileService) Paths() (configPath, credentialsPath string, err error) { - configPath, err = s.repository.ConfigPath() - if err != nil { - return "", "", err - } - credentialsPath, err = s.repository.CredentialsPath() - return configPath, credentialsPath, err -} - -func (s *ProfileService) Lookup(domain profile.Domain, backend string) (catalog.BackendSpec, error) { - spec, ok := s.catalog.Lookup(catalog.Domain(domain), backend) - if !ok { - return catalog.BackendSpec{}, cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("(%s, %s) 不是支持的 (domain, backend) 组合", domain, backend), - ).WithContext(map[string]any{"domain": string(domain), "backend": backend}) - } - return spec, nil -} - -func (s *ProfileService) ParsePair(pair string) (catalog.BackendSpec, error) { - spec, ok := s.catalog.LookupPair(pair) - if !ok { - return catalog.BackendSpec{}, cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("未知 (domain, backend) pair %q;可选:%v。", pair, s.catalog.SortedPairs()), - ) - } - return spec, nil -} - -// ProfileBackends preserves the catalog's product display order while -// excluding non-configurable implementation backends such as env/dotenv. -func (s *ProfileService) ProfileBackends() []catalog.BackendSpec { - return s.catalog.ProfileBackends() -} - -type ProfileSection struct { - Spec catalog.BackendSpec - Payload any - Names []string - Default string -} - -func (s *ProfileService) Section( - config *profile.Config, - domain profile.Domain, - backend string, -) (ProfileSection, error) { - spec, err := s.Lookup(domain, backend) - if err != nil { - return ProfileSection{}, err - } - section, ok := profile.InspectSection(config, spec) - if !ok { - return ProfileSection{}, cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("profile schema 尚未实现 %s", spec.Pair), - ) - } - sort.Strings(section.Names) - return ProfileSection{ - Spec: spec, Payload: section.Payload, Names: section.Names, Default: section.Default, - }, nil -} - -func (s *ProfileService) CredentialSource( - config *profile.Config, - domain profile.Domain, - backend, name string, -) string { - spec, err := s.Lookup(domain, backend) - if err != nil { - return "" - } - value, ok := profile.LookupStored(config, spec, name) - if !ok { - return "" - } - return profile.CredentialSource(spec, value) -} - -func (s *ProfileService) DecodeProfile( - domain profile.Domain, - backend string, - raw json.RawMessage, -) (profile.Profile, error) { - spec, err := s.Lookup(domain, backend) - if err != nil { - return profile.Profile{}, err - } - return profile.Decode(spec, raw) -} - -type UpsertProfileInput struct { - Domain profile.Domain - Backend string - Name string - Profile profile.Profile - SetDefault bool - PreserveMasked bool -} - -type UpsertProfileResult struct { - Updated bool - Default bool -} - -func (s *ProfileService) Upsert(input UpsertProfileInput) (UpsertProfileResult, error) { - spec, err := s.Lookup(input.Domain, input.Backend) - if err != nil { - return UpsertProfileResult{}, err - } - containsMasked, err := s.containsMaskedCredential(spec, input.Profile) - if err != nil { - return UpsertProfileResult{}, err - } - if input.PreserveMasked && containsMasked { - config, err := s.Load() - if err != nil { - return UpsertProfileResult{}, err - } - if err := s.preserveMaskedCredentials(config, spec, input.Name, &input.Profile); err != nil { - return UpsertProfileResult{}, err - } - } - updated, err := s.repository.Upsert( - input.Domain, input.Backend, input.Name, input.Profile, input.SetDefault, - ) - if err != nil { - return UpsertProfileResult{}, err - } - config, err := s.Load() - if err != nil { - return UpsertProfileResult{}, err - } - section, err := s.Section(config, input.Domain, input.Backend) - if err != nil { - return UpsertProfileResult{}, err - } - return UpsertProfileResult{Updated: updated, Default: section.Default == input.Name}, nil -} - -func (s *ProfileService) Remove(domain profile.Domain, backend, name string) error { - if _, err := s.Lookup(domain, backend); err != nil { - return err - } - return s.repository.Remove(domain, backend, name) -} - -func (s *ProfileService) SetDefault(domain profile.Domain, backend, name string) error { - if _, err := s.Lookup(domain, backend); err != nil { - return err - } - return s.repository.SetDefault(domain, backend, name) -} - -func (s *ProfileService) BindWorkspaceProfile( - workspaceID, workspaceName, root, projectName string, - domain profile.Domain, - backend, name string, -) error { - if _, err := s.Lookup(domain, backend); err != nil { - return err - } - return s.repository.BindWorkspaceProfile( - workspaceID, workspaceName, root, projectName, domain, backend, name, - ) -} - -func (s *ProfileService) UnbindWorkspaceProfile( - workspaceID, projectName string, - domain profile.Domain, - backend string, -) error { - if _, err := s.Lookup(domain, backend); err != nil { - return err - } - return s.repository.UnbindWorkspaceProfile(workspaceID, projectName, domain, backend) -} - -func (s *ProfileService) BindEnvironmentProfile( - workspaceID, workspaceName, root, projectName, environment string, - domain profile.Domain, - backend, name string, -) error { - if _, err := s.Lookup(domain, backend); err != nil { - return err - } - return s.repository.BindEnvironmentProfile( - workspaceID, workspaceName, root, projectName, environment, domain, backend, name, - ) -} - -func (s *ProfileService) UnbindEnvironmentProfile( - root, projectName, environment string, - domain profile.Domain, - backend string, -) error { - if _, err := s.Lookup(domain, backend); err != nil { - return err - } - return s.repository.UnbindEnvironmentProfile(root, projectName, environment, domain, backend) -} - -func (s *ProfileService) EnvironmentProfileBinding( - root, projectName, environment string, - domain profile.Domain, - backend string, -) (string, error) { - if _, err := s.Lookup(domain, backend); err != nil { - return "", err - } - return s.repository.EnvironmentProfileBinding(root, projectName, environment, domain, backend) -} - -func (s *ProfileService) Resolve(input profile.ResolveInput) (*profile.Resolved, error) { - if _, err := s.Lookup(input.Domain, input.Backend); err != nil { - return nil, err - } - return s.repository.Resolve(input) -} - -func (s *ProfileService) HasCredentialFields(domain profile.Domain, backend string) bool { - spec, err := s.Lookup(domain, backend) - if err != nil { - return false - } - for _, field := range spec.Profile.Fields { - if field.Type == catalog.FieldSecret { - return true - } - } - return false -} diff --git a/packages/cli/internal/application/configure/profile_service_test.go b/packages/cli/internal/application/configure/profile_service_test.go deleted file mode 100644 index a13c09a6..00000000 --- a/packages/cli/internal/application/configure/profile_service_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package configure - -import ( - "encoding/json" - "reflect" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" -) - -type profileRepositoryStub struct { - config *profile.Config - upsert profile.Profile - updated bool - unbound struct { - workspaceID string - projectName string - domain profile.Domain - backend string - } -} - -func (r *profileRepositoryStub) Load() (*profile.Config, *profile.CredentialsFile, error) { - return r.config, &profile.CredentialsFile{Version: profile.SchemaVersion}, nil -} - -func (r *profileRepositoryStub) Upsert( - domain profile.Domain, - backend, name string, - value profile.Profile, - setDefault bool, -) (bool, error) { - r.upsert = value - if domain == profile.DomainDeploy && backend == "vercel" && value.Vercel != nil { - if r.config.DeployVercel.Profiles == nil { - r.config.DeployVercel.Profiles = map[string]profile.VercelProfile{} - } - r.config.DeployVercel.Profiles[name] = *value.Vercel - if setDefault || r.config.DeployVercel.Default == "" { - r.config.DeployVercel.Default = name - } - } - return r.updated, nil -} - -func (*profileRepositoryStub) Remove(profile.Domain, string, string) error { return nil } -func (*profileRepositoryStub) SetDefault(profile.Domain, string, string) error { return nil } -func (*profileRepositoryStub) BindWorkspaceProfile( - string, string, string, string, profile.Domain, string, string, -) error { - return nil -} -func (r *profileRepositoryStub) UnbindWorkspaceProfile( - workspaceID, projectName string, domain profile.Domain, backend string, -) error { - r.unbound.workspaceID = workspaceID - r.unbound.projectName = projectName - r.unbound.domain = domain - r.unbound.backend = backend - return nil -} - -func (*profileRepositoryStub) BindEnvironmentProfile( - string, string, string, string, string, profile.Domain, string, string, -) error { - return nil -} - -func (*profileRepositoryStub) UnbindEnvironmentProfile( - string, string, string, profile.Domain, string, -) error { - return nil -} -func (*profileRepositoryStub) EnvironmentProfileBinding( - string, string, string, profile.Domain, string, -) (string, error) { - return "", nil -} -func (*profileRepositoryStub) Resolve(profile.ResolveInput) (*profile.Resolved, error) { - return nil, nil -} -func (*profileRepositoryStub) ConfigPath() (string, error) { return "/config.json", nil } -func (*profileRepositoryStub) CredentialsPath() (string, error) { return "/credentials.json", nil } - -func testProfileService(t *testing.T, repository ProfileRepository) *ProfileService { - t.Helper() - service, err := NewProfileService(catalog.Builtin(), repository) - if err != nil { - t.Fatal(err) - } - return service -} - -func TestProfileServiceUsesCatalogOrder(t *testing.T) { - t.Parallel() - - service := testProfileService(t, &profileRepositoryStub{config: &profile.Config{}}) - got := make([]string, 0) - for _, backend := range service.ProfileBackends() { - got = append(got, backend.Pair) - } - want := []string{ - "env/infisical", - "deploy/aliyun-oss", - "deploy/tencent-cos", - "deploy/aws-s3", - "deploy/minio", - "deploy/rustfs", - "deploy/r2", - "deploy/kustomize", - "deploy/vercel", - "deploy/cloudflare", - "deploy/edgeone", - "container/docker", - "container/dockerhub", - "container/ghcr", - "container/acr", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("ProfileBackends() = %#v, want %#v", got, want) - } -} - -func TestProfileServiceUnbindsProjectProfileThroughRepository(t *testing.T) { - t.Parallel() - repository := &profileRepositoryStub{config: &profile.Config{}} - service := testProfileService(t, repository) - if err := service.UnbindWorkspaceProfile( - "ws-demo", "web", profile.DomainDeploy, catalog.DeployVercel, - ); err != nil { - t.Fatal(err) - } - if repository.unbound.workspaceID != "ws-demo" || - repository.unbound.projectName != "web" || - repository.unbound.domain != profile.DomainDeploy || - repository.unbound.backend != catalog.DeployVercel { - t.Fatalf("unbind input = %#v", repository.unbound) - } -} - -func TestProfileServiceDecodesTypedProfile(t *testing.T) { - t.Parallel() - - service := testProfileService(t, &profileRepositoryStub{config: &profile.Config{}}) - value, err := service.DecodeProfile( - profile.DomainContainer, - "ghcr", - json.RawMessage(`{"namespace":"team","credentials":{"username":"octo","password":"token"}}`), - ) - if err != nil { - t.Fatal(err) - } - if value.Container == nil || value.Container.Namespace != "team" || - value.Container.Credentials == nil || value.Container.Credentials.Password != "token" { - t.Fatalf("decoded profile = %#v", value) - } -} - -func TestProfileServiceRejectsCatalogProfileDrift(t *testing.T) { - t.Parallel() - - backendCatalog, err := catalog.New(catalog.BackendSpec{ - ID: catalog.BackendID{Domain: catalog.DomainEnv, Name: "infisical"}, - Pair: "env/infisical", - Capabilities: []catalog.Capability{catalog.CapabilityEnvGet}, - Profile: catalog.ProfileSpec{ - Configurable: true, - Type: catalog.ProfileTypeInfisical, - Fields: []catalog.FieldSpec{{ - Path: "credentials/notARealField", InputName: "invalid", Type: catalog.FieldSecret, LabelKey: "test", - }}, - }, - }) - if err != nil { - t.Fatal(err) - } - if _, err := NewProfileService(backendCatalog, &profileRepositoryStub{config: &profile.Config{}}); err == nil { - t.Fatal("NewProfileService() accepted a Catalog field absent from the typed profile") - } -} - -func TestMaskConfigUsesCatalogFieldPolicy(t *testing.T) { - t.Parallel() - - backendCatalog, err := catalog.New(catalog.BackendSpec{ - ID: catalog.BackendID{Domain: catalog.DomainEnv, Name: "infisical"}, - Pair: "env/infisical", - Capabilities: []catalog.Capability{catalog.CapabilityEnvGet}, - Profile: catalog.ProfileSpec{ - Configurable: true, - Type: catalog.ProfileTypeInfisical, - Fields: []catalog.FieldSpec{ - {Path: "credentials/clientId", InputName: "client-id", Type: catalog.FieldSecret, LabelKey: "test.clientId"}, - {Path: "credentials/clientSecret", InputName: "client-secret", Type: catalog.FieldString, LabelKey: "test.clientSecret"}, - }, - }, - }) - if err != nil { - t.Fatal(err) - } - service, err := NewProfileService(backendCatalog, &profileRepositoryStub{config: &profile.Config{}}) - if err != nil { - t.Fatal(err) - } - masked, err := service.MaskConfig(profile.Config{ - EnvInfisical: profile.Section[profile.InfisicalProfile]{ - Profiles: map[string]profile.InfisicalProfile{ - "work": {Credentials: &profile.InfisicalCredentials{ - ClientID: "catalog-secret", ClientSecret: "catalog-visible", - }}, - }, - }, - }) - if err != nil { - t.Fatal(err) - } - credentials := masked.EnvInfisical.Profiles["work"].Credentials - if credentials.ClientID != MaskedCredential || credentials.ClientSecret != "catalog-visible" { - t.Fatalf("Catalog mask policy was not applied: %#v", credentials) - } -} - -func TestProfileServicePreservesMaskedCredential(t *testing.T) { - t.Parallel() - - repository := &profileRepositoryStub{config: &profile.Config{ - DeployVercel: profile.Section[profile.VercelProfile]{ - Default: "production", - Profiles: map[string]profile.VercelProfile{ - "production": { - Team: "old-team", - Credentials: &profile.VercelCredentials{APIToken: "real-token"}, - }, - }, - }, - }} - service := testProfileService(t, repository) - result, err := service.Upsert(UpsertProfileInput{ - Domain: profile.DomainDeploy, - Backend: "vercel", - Name: "production", - Profile: profile.Profile{ - Backend: "vercel", - Vercel: &profile.VercelProfile{ - Team: "new-team", - Credentials: &profile.VercelCredentials{APIToken: MaskedCredential}, - }, - }, - PreserveMasked: true, - }) - if err != nil { - t.Fatal(err) - } - if !result.Default { - t.Fatal("updated default profile no longer reported as default") - } - if got := repository.upsert.Vercel.Credentials.APIToken; got != "real-token" { - t.Fatalf("saved token = %q, want preserved real token", got) - } -} - -func TestProfileServiceMaskPolicies(t *testing.T) { - t.Parallel() - - service := testProfileService(t, &profileRepositoryStub{config: &profile.Config{}}) - config := profile.Config{DeployAWSS3: profile.Section[profile.S3Profile]{ - Profiles: map[string]profile.S3Profile{ - "work": {Credentials: &profile.S3Credentials{ - AccessKeyID: "visible-id", AccessKeySecret: "secret", - }}, - }, - }} - maskedConfig, err := service.MaskConfig(config) - if err != nil { - t.Fatal(err) - } - credentials := maskedConfig.DeployAWSS3.Profiles["work"].Credentials - if credentials.AccessKeyID != "visible-id" || credentials.AccessKeySecret != MaskedCredential { - t.Fatalf("HTTP mask = %#v", credentials) - } - maskedProfile, err := service.MaskProfile(profile.Profile{ - S3: &profile.S3Profile{Credentials: &profile.S3Credentials{ - AccessKeyID: "id", AccessKeySecret: "secret", - }}, - }) - if err != nil { - t.Fatal(err) - } - if got := maskedProfile.S3.Credentials; got.AccessKeyID != MaskedCredential || got.AccessKeySecret != MaskedCredential { - t.Fatalf("CLI mask = %#v", got) - } -} diff --git a/packages/cli/internal/application/deployment/environment.go b/packages/cli/internal/application/deployment/environment.go deleted file mode 100644 index 456d5fab..00000000 --- a/packages/cli/internal/application/deployment/environment.go +++ /dev/null @@ -1,153 +0,0 @@ -package deployment - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -func resolveEnvProvider(manifest *workspace.Manifest, flag string) (string, error) { - id := strings.ToLower(strings.TrimSpace(flag)) - if id == "" { - id = workspace.EnvBackend(manifest) - } - if id == "" { - id = workspace.EnvBackendDotenv - } - if id != workspace.EnvBackendDotenv && id != workspace.EnvBackendInfisical { - return "", cliErrors.New(cliErrors.BACKEND_ID_UNKNOWN, - "--env-provider 取值非法:"+id+"(合法值: dotenv | infisical)") - } - return id, nil -} - -func applyEnvOverride(manifest *workspace.Manifest, environment string) error { - environment = strings.TrimSpace(environment) - if environment == "" || manifest == nil { - return nil - } - if err := validateDeclaredEnvironment(manifest, environment); err != nil { - return err - } - for index := range manifest.Projects { - if manifest.Projects[index].Domains == nil || manifest.Projects[index].Domains.Deploy == nil { - continue - } - if err := setDeployEnvironment(manifest.Projects[index].Domains.Deploy, environment); err != nil { - return err - } - } - return nil -} - -func validateProjectEnvironments(manifest *workspace.Manifest) error { - if manifest == nil { - return nil - } - for _, project := range manifest.Projects { - if project.Domains == nil || project.Domains.Deploy == nil { - continue - } - environment, err := readDeployEnvironment(project.Domains.Deploy) - if err != nil { - return err - } - if err := validateDeclaredEnvironment(manifest, environment); err != nil { - return err - } - } - return nil -} - -func readDeployEnvironment(deployment *workspace.ProjectDeployBackend) (string, error) { - if deployment == nil || len(deployment.Config) == 0 { - return "", nil - } - config := struct { - Environment string `json:"env,omitempty"` - }{} - if err := json.Unmarshal(deployment.Config, &config); err != nil { - return "", err - } - return strings.TrimSpace(config.Environment), nil -} - -// deployProfileEnvironment returns the environment whose machine-local -// Profile binding applies to a deploy target. A project without an explicit -// deploy environment follows the deploy contract's production default. -func deployProfileEnvironment(manifest *workspace.Manifest, projectName string) string { - project := findManifestProject(manifest, projectName) - if project != nil && project.Domains != nil { - if environment, err := readDeployEnvironment(project.Domains.Deploy); err == nil && environment != "" { - return environment - } - } - return "prod" -} - -func effectiveDeployEnvironment( - manifest *workspace.Manifest, - projectName, override string, -) string { - if environment := strings.TrimSpace(override); environment != "" { - return environment - } - return deployProfileEnvironment(manifest, projectName) -} - -func setDeployEnvironment(deployment *workspace.ProjectDeployBackend, environment string) error { - if deployment == nil { - return nil - } - config := map[string]json.RawMessage{} - if len(deployment.Config) > 0 { - if err := json.Unmarshal(deployment.Config, &config); err != nil { - return err - } - } - if environment == "" { - delete(config, "env") - } else { - raw, err := json.Marshal(environment) - if err != nil { - return err - } - config["env"] = raw - } - if len(config) == 0 { - deployment.Config = nil - return nil - } - raw, err := json.Marshal(config) - if err != nil { - return err - } - deployment.Config = raw - return nil -} - -func validateDeclaredEnvironment(manifest *workspace.Manifest, environment string) error { - environment = strings.TrimSpace(environment) - if environment == "" { - return nil - } - var declared []string - if manifest.Environments != nil { - declared = manifest.Environments.Names - } - if len(declared) == 0 { - return nil - } - for _, candidate := range declared { - if candidate == environment { - return nil - } - } - return cliErrors.New(cliErrors.ENV_UNKNOWN_ENVIRONMENT, - fmt.Sprintf("环境 %q 未在 manifest.environments.names 中(已声明:%s)。", - environment, strings.Join(declared, ", "))). - WithContext(map[string]any{"requested": environment, "environments": declared}) -} diff --git a/packages/cli/internal/application/deployment/environment_test.go b/packages/cli/internal/application/deployment/environment_test.go deleted file mode 100644 index e3dce93f..00000000 --- a/packages/cli/internal/application/deployment/environment_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package deployment - -import ( - "encoding/json" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func deployConfig(t *testing.T, value any) json.RawMessage { - t.Helper() - raw, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return raw -} - -func TestApplyEnvOverridePreservesBackendConfig(t *testing.T) { - manifest := &workspace.Manifest{ - Environments: &workspace.Environments{Names: []string{"dev", "staging", "prod"}}, - Projects: []workspace.ManifestProject{ - {Name: "web", Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendVercel, - Config: deployConfig(t, map[string]string{"env": "prod", "projectId": "project-1"}), - }}}, - {Name: "api", Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendCloudflare, - }}}, - }, - } - if err := applyEnvOverride(manifest, "staging"); err != nil { - t.Fatal(err) - } - for _, project := range manifest.Projects { - environment, err := readDeployEnvironment(project.Domains.Deploy) - if err != nil || environment != "staging" { - t.Fatalf("%s environment = %q, %v", project.Name, environment, err) - } - } - var config map[string]json.RawMessage - if err := json.Unmarshal(manifest.Projects[0].Domains.Deploy.Config, &config); err != nil { - t.Fatal(err) - } - if string(config["projectId"]) != `"project-1"` { - t.Fatalf("backend config was not preserved: %s", manifest.Projects[0].Domains.Deploy.Config) - } -} - -func TestEnvironmentPolicyRejectsUnknownNames(t *testing.T) { - manifest := &workspace.Manifest{ - Environments: &workspace.Environments{Names: []string{"dev", "prod"}}, - Projects: []workspace.ManifestProject{{ - Name: "web", - Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendVercel, - Config: deployConfig(t, map[string]string{"env": "typo"}), - }}, - }}, - } - if err := validateProjectEnvironments(manifest); errorCode(err) != "ENV_UNKNOWN_ENVIRONMENT" { - t.Fatalf("validateProjectEnvironments() = %v", err) - } - if err := applyEnvOverride(manifest, "qa"); errorCode(err) != "ENV_UNKNOWN_ENVIRONMENT" { - t.Fatalf("applyEnvOverride() = %v", err) - } -} - -func TestEnvironmentPolicyAllowsUndeclaredNames(t *testing.T) { - manifest := &workspace.Manifest{Projects: []workspace.ManifestProject{{ - Name: "web", - Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendVercel, - }}, - }}} - if err := applyEnvOverride(manifest, "preview"); err != nil { - t.Fatal(err) - } - if environment, err := readDeployEnvironment(manifest.Projects[0].Domains.Deploy); err != nil || environment != "preview" { - t.Fatalf("environment = %q, %v", environment, err) - } -} - -func errorCode(err error) string { - if coded, ok := err.(interface{ ErrorCode() string }); ok { - return coded.ErrorCode() - } - return "" -} diff --git a/packages/cli/internal/application/deployment/planning.go b/packages/cli/internal/application/deployment/planning.go deleted file mode 100644 index b762011e..00000000 --- a/packages/cli/internal/application/deployment/planning.go +++ /dev/null @@ -1,154 +0,0 @@ -package deployment - -import ( - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -// PlanRequest contains the transport-neutral choices that affect which -// projects a deploy command should execute or configure. -type PlanRequest struct { - ProjectRoot string - Manifest *workspace.Manifest - Templates *template.Registry - Project string - Backend string -} - -// TargetPlan is one of three states: -// - Targets is non-empty when deployment can execute immediately; -// - ProjectChoices asks the transport to choose a deployable project; -// - Setup asks the transport to collect any interactive profile/backend -// input before configuring a project's first deployment. -type TargetPlan struct { - Targets []Target - ProjectChoices []string - Setup *TargetSetup -} - -// TargetSetup describes a compatible first-deployment configuration without -// owning prompts or workspace mutation. Backend is empty when the transport -// still needs to choose one of CompatibleBackends. -type TargetSetup struct { - Project *workspace.ManifestProject - Template *template.Template - CompatibleBackends []string - Backend string -} - -// PlanTargets owns the reusable target-selection policy while leaving all -// interactive choices to the transport. -func (s *Service) PlanTargets(request PlanRequest) (TargetPlan, error) { - configured, err := configuredTargets(request.ProjectRoot, request.Manifest) - if err != nil { - return TargetPlan{}, err - } - - selector := strings.TrimSpace(request.Project) - backend := normalizeBackend(request.Backend) - if selector == "" && backend == "" && len(configured) > 0 { - return TargetPlan{Targets: configured}, nil - } - - project := findManifestProject(request.Manifest, selector) - if selector == "" { - if request.Manifest != nil && len(request.Manifest.Projects) == 1 && backend != "" { - project = &request.Manifest.Projects[0] - } else { - choices := deployableProjectNames(s.catalog, request.Manifest, request.Templates) - if len(choices) == 0 { - return TargetPlan{}, cliErrors.New( - cliErrors.BACKEND_NOT_ENABLED, - i18n.T("deploy.no_compatible_projects"), - ) - } - return TargetPlan{ProjectChoices: choices}, nil - } - } - if project == nil { - return TargetPlan{}, cliErrors.New( - cliErrors.SUBPROJECT_NOT_FOUND, - i18n.Tf("deploy.project_not_found", selector), - ).WithContext(map[string]any{ - "selector": selector, "available_projects": workspace.ProjectNames(request.Manifest), - }) - } - - existingBackend := workspace.DeployForProject(request.Manifest, project.Name).Backend - if existingBackend != "" && backend == "" { - return TargetPlan{Targets: []Target{ - manifestProjectTarget(request.ProjectRoot, project, existingBackend), - }}, nil - } - - projectTemplate := templateForProject(request.Templates, project) - compatible := compatibleBackends(s.catalog, projectTemplate) - if len(compatible) == 0 { - return TargetPlan{}, cliErrors.New( - cliErrors.BACKEND_NOT_ENABLED, - i18n.Tf("deploy.project_not_deployable", project.Name), - ) - } - setup := &TargetSetup{ - Project: project, Template: projectTemplate, - CompatibleBackends: compatible, Backend: backend, - } - if backend != "" { - if _, err := setup.ResolveTarget(request.ProjectRoot, backend); err != nil { - return TargetPlan{}, err - } - } - return TargetPlan{Setup: setup}, nil -} - -// ResolveTarget validates a transport-selected backend and returns the target -// that will be published to the manifest after profile setup succeeds. -func (s TargetSetup) ResolveTarget(projectRoot, backend string) (Target, error) { - backend = normalizeBackend(backend) - for _, candidate := range s.CompatibleBackends { - if candidate == backend { - return manifestProjectTarget(projectRoot, s.Project, backend), nil - } - } - projectName := "" - if s.Project != nil { - projectName = s.Project.Name - } - return Target{}, cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - i18n.Tf( - "deploy.provider_incompatible", - backend, - projectName, - strings.Join(s.CompatibleBackends, ", "), - ), - ) -} - -func deployableProjectNames( - backendCatalog *catalog.Catalog, - manifest *workspace.Manifest, - registry *template.Registry, -) []string { - if manifest == nil { - return nil - } - names := make([]string, 0, len(manifest.Projects)) - for index := range manifest.Projects { - project := &manifest.Projects[index] - if len(compatibleBackends(backendCatalog, templateForProject(registry, project))) == 0 { - continue - } - names = append(names, project.Name) - } - return names -} - -func normalizeBackend(value string) string { - return strings.TrimPrefix(strings.TrimSpace(value), "deploy/") -} diff --git a/packages/cli/internal/application/deployment/planning_test.go b/packages/cli/internal/application/deployment/planning_test.go deleted file mode 100644 index e175640a..00000000 --- a/packages/cli/internal/application/deployment/planning_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package deployment - -import ( - "reflect" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" -) - -func newPlanningService(t *testing.T) *Service { - t.Helper() - service, err := NewService( - catalog.Builtin(), - deployport.MustRegistry( - providerStub{id: workspace.DeployBackendVercel}, - providerStub{id: workspace.DeployBackendCloudflare}, - ), - profileResolverStub{}, - secrets.MustRegistry(), - builderStub{}, - ) - if err != nil { - t.Fatal(err) - } - return service -} - -func planningFixture(t *testing.T) (string, *workspace.Manifest, *template.Registry) { - t.Helper() - root := t.TempDir() - manifest := &workspace.Manifest{Projects: []workspace.ManifestProject{ - { - Name: "web", RelativeDir: "apps/web", TemplateID: "web-template", - Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendVercel, - }}, - }, - {Name: "api", RelativeDir: "apps/api", TemplateID: "web-template"}, - {Name: "library", RelativeDir: "packages/library", TemplateID: "library-template"}, - }} - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - registry := &template.Registry{Templates: []template.Template{ - {ID: "web-template", Compat: map[string][]string{ - "deploy": {workspace.DeployBackendVercel, workspace.DeployBackendCloudflare}, - }}, - {ID: "library-template", Compat: map[string][]string{"deploy": {}}}, - }} - return root, manifest, registry -} - -func TestPlanTargetsUsesAllConfiguredTargetsByDefault(t *testing.T) { - root, manifest, registry := planningFixture(t) - plan, err := newPlanningService(t).PlanTargets(PlanRequest{ - ProjectRoot: root, Manifest: manifest, Templates: registry, - }) - if err != nil { - t.Fatal(err) - } - if len(plan.Targets) != 1 || plan.Targets[0].Project.Name != "web" || - plan.Setup != nil || len(plan.ProjectChoices) != 0 { - t.Fatalf("plan = %#v", plan) - } -} - -func TestPlanTargetsResolvesExistingTargetByRelativePath(t *testing.T) { - root, manifest, registry := planningFixture(t) - plan, err := newPlanningService(t).PlanTargets(PlanRequest{ - ProjectRoot: root, Manifest: manifest, Templates: registry, - Project: "./apps/web/", - }) - if err != nil { - t.Fatal(err) - } - if len(plan.Targets) != 1 || plan.Targets[0].Project.Name != "web" || plan.Setup != nil { - t.Fatalf("plan = %#v", plan) - } -} - -func TestPlanTargetsReturnsDeployableProjectChoices(t *testing.T) { - root, manifest, registry := planningFixture(t) - plan, err := newPlanningService(t).PlanTargets(PlanRequest{ - ProjectRoot: root, Manifest: manifest, Templates: registry, - Backend: workspace.DeployBackendCloudflare, - }) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(plan.ProjectChoices, []string{"web", "api"}) || - len(plan.Targets) != 0 || plan.Setup != nil { - t.Fatalf("plan = %#v", plan) - } -} - -func TestPlanTargetsReturnsFirstDeploymentSetup(t *testing.T) { - root, manifest, registry := planningFixture(t) - plan, err := newPlanningService(t).PlanTargets(PlanRequest{ - ProjectRoot: root, Manifest: manifest, Templates: registry, - Project: "api", Backend: "deploy/cloudflare", - }) - if err != nil { - t.Fatal(err) - } - if plan.Setup == nil || plan.Setup.Project.Name != "api" || - plan.Setup.Backend != workspace.DeployBackendCloudflare || - !reflect.DeepEqual(plan.Setup.CompatibleBackends, - []string{workspace.DeployBackendVercel, workspace.DeployBackendCloudflare}) { - t.Fatalf("plan = %#v", plan) - } - target, err := plan.Setup.ResolveTarget(root, workspace.DeployBackendCloudflare) - if err != nil || target.Project.Name != "api" || target.Backend != workspace.DeployBackendCloudflare { - t.Fatalf("ResolveTarget() = %#v, %v", target, err) - } -} - -func TestPlanTargetsInfersTheOnlyProjectWhenBackendIsExplicit(t *testing.T) { - root := t.TempDir() - manifest := &workspace.Manifest{Projects: []workspace.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", TemplateID: "web-template", - }}} - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - registry := &template.Registry{Templates: []template.Template{{ - ID: "web-template", Compat: map[string][]string{ - "deploy": {workspace.DeployBackendVercel}, - }, - }}} - plan, err := newPlanningService(t).PlanTargets(PlanRequest{ - ProjectRoot: root, Manifest: manifest, Templates: registry, - Backend: workspace.DeployBackendVercel, - }) - if err != nil { - t.Fatal(err) - } - if plan.Setup == nil || plan.Setup.Project.Name != "web" || - plan.Setup.Backend != workspace.DeployBackendVercel { - t.Fatalf("plan = %#v", plan) - } -} - -func TestPlanTargetsRejectsUnknownProjectAndIncompatibleBackend(t *testing.T) { - root, manifest, registry := planningFixture(t) - service := newPlanningService(t) - for _, tt := range []struct { - name string - request PlanRequest - code string - }{ - { - name: "unknown project", - request: PlanRequest{ProjectRoot: root, Manifest: manifest, Templates: registry, - Project: "missing"}, - code: "SUBPROJECT_NOT_FOUND", - }, - { - name: "incompatible backend", - request: PlanRequest{ProjectRoot: root, Manifest: manifest, Templates: registry, - Project: "api", Backend: workspace.DeployBackendKustomize}, - code: "PROFILE_BACKEND_INVALID", - }, - { - name: "project is not deployable", - request: PlanRequest{ProjectRoot: root, Manifest: manifest, Templates: registry, - Project: "library"}, - code: "BACKEND_NOT_ENABLED", - }, - } { - t.Run(tt.name, func(t *testing.T) { - if _, err := service.PlanTargets(tt.request); errorCode(err) != tt.code { - t.Fatalf("PlanTargets() error = %v, code = %q", err, errorCode(err)) - } - }) - } -} diff --git a/packages/cli/internal/application/deployment/service.go b/packages/cli/internal/application/deployment/service.go deleted file mode 100644 index 44661c75..00000000 --- a/packages/cli/internal/application/deployment/service.go +++ /dev/null @@ -1,57 +0,0 @@ -package deployment - -import ( - "context" - "fmt" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" -) - -type ProfileResolver interface { - Resolve(profile.ResolveInput) (*profile.Resolved, error) -} - -type Service struct { - catalog *catalog.Catalog - providers *deployport.Registry - profiles ProfileResolver - loaders *secrets.Registry - builder deployport.Builder -} - -func NewService( - backendCatalog *catalog.Catalog, - providers *deployport.Registry, - profiles ProfileResolver, - loaders *secrets.Registry, - builder deployport.Builder, -) (*Service, error) { - if backendCatalog == nil || providers == nil || profiles == nil || loaders == nil || builder == nil { - return nil, fmt.Errorf("application: deploy catalog, providers, profiles, loaders, and builder are required") - } - return &Service{ - catalog: backendCatalog, providers: providers, profiles: profiles, loaders: loaders, builder: builder, - }, nil -} - -func (s *Service) apply(ctx context.Context, backend string, input deployport.ApplyInput) (*deployport.ApplyResult, error) { - spec, ok := s.catalog.Lookup(catalog.DomainDeploy, backend) - if !ok || !spec.Has(catalog.CapabilityDeploy) { - return nil, cliErrors.New( - cliErrors.BACKEND_NOT_ENABLED, - fmt.Sprintf("deploy backend %q 不支持 deploy capability", backend), - ) - } - provider, ok := s.providers.Get(backend) - if !ok { - return nil, cliErrors.New( - cliErrors.BACKEND_NOT_ENABLED, - fmt.Sprintf("未知 deploy 后端 %q(可用:%v)", backend, s.providers.IDs()), - ) - } - return provider.Apply(ctx, input) -} diff --git a/packages/cli/internal/application/deployment/service_test.go b/packages/cli/internal/application/deployment/service_test.go deleted file mode 100644 index 23643047..00000000 --- a/packages/cli/internal/application/deployment/service_test.go +++ /dev/null @@ -1,283 +0,0 @@ -package deployment - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" -) - -type providerStub struct { - id string - apply func(context.Context, deployport.ApplyInput) (*deployport.ApplyResult, error) -} - -func (p providerStub) ID() string { return p.id } - -func (p providerStub) Apply(ctx context.Context, input deployport.ApplyInput) (*deployport.ApplyResult, error) { - if p.apply != nil { - return p.apply(ctx, input) - } - return &deployport.ApplyResult{Schema: "deploy/test"}, nil -} - -type profileResolverStub struct { - resolved *profile.Resolved - err error - resolve func(profile.ResolveInput) (*profile.Resolved, error) -} - -func (s profileResolverStub) Resolve(input profile.ResolveInput) (*profile.Resolved, error) { - if s.resolve != nil { - return s.resolve(input) - } - return s.resolved, s.err -} - -type builderStub struct { - build func(context.Context, deployport.BuildInput) ([]string, error) -} - -func (s builderStub) Build(ctx context.Context, input deployport.BuildInput) ([]string, error) { - if s.build == nil { - return nil, nil - } - return s.build(ctx, input) -} - -type loaderStub struct { - id string - vars map[string]string -} - -type recordingLoader struct { - id string - environments []string - vars map[string]string -} - -func (s *recordingLoader) ID() string { return s.id } -func (*recordingLoader) Priority() secrets.Priority { return secrets.PriorityFilesystem } -func (*recordingLoader) Available(string) bool { return true } -func (s *recordingLoader) Load( - _ context.Context, - _, _, environment string, -) (map[string]string, error) { - s.environments = append(s.environments, environment) - return s.vars, nil -} - -func (s loaderStub) ID() string { return s.id } -func (loaderStub) Priority() secrets.Priority { return secrets.PriorityFilesystem } -func (loaderStub) Available(string) bool { return true } -func (s loaderStub) Load(context.Context, string, string, string) (map[string]string, error) { - return s.vars, nil -} - -type observerStub struct { - events []string - results []TargetResult -} - -func (s *observerStub) TargetStarted(target Target) { - s.events = append(s.events, "start:"+target.Project.Name) -} - -func (s *observerStub) TargetCompleted(result TargetResult) error { - s.events = append(s.events, "complete:"+result.Target.Project.Name) - s.results = append(s.results, result) - return nil -} - -func TestServiceDispatchesThroughInjectedRegistry(t *testing.T) { - service, err := NewService( - catalog.Builtin(), - deployport.MustRegistry(providerStub{id: "vercel"}), - profileResolverStub{}, - secrets.MustRegistry(), - builderStub{}, - ) - if err != nil { - t.Fatal(err) - } - result, err := service.apply(context.Background(), "vercel", deployport.ApplyInput{}) - if err != nil || result.Schema != "deploy/test" { - t.Fatalf("Apply() = %#v, %v", result, err) - } -} - -func TestExecuteOwnsDeploymentWorkflowOrder(t *testing.T) { - root := t.TempDir() - manifest := &workspace.Manifest{ - Workspace: &workspace.ManifestWorkspace{ID: "ws-demo", Name: "demo"}, - Environments: &workspace.Environments{Names: []string{"dev", "staging"}, Default: "dev"}, - Domains: &workspace.WorkspaceDomains{ - Env: &workspace.BackendRef{Kind: workspace.EnvBackendDotenv}, - }, - Projects: []workspace.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", PackageManager: "pnpm", - Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendVercel}, - }, - }}, - } - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - target := manifestProjectTarget(root, &manifest.Projects[0], workspace.DeployBackendVercel) - events := []string{} - resolver := profileResolverStub{resolve: func(input profile.ResolveInput) (*profile.Resolved, error) { - events = append(events, "profile") - if input.WorkspaceID != "ws-demo" || input.WorkspaceRoot != root || - input.Environment != "staging" || input.ProjectName != "web" || input.FlagOverride != "prod" { - t.Fatalf("profile input = %#v", input) - } - return &profile.Resolved{Name: "prod"}, nil - }} - builder := builderStub{build: func(_ context.Context, input deployport.BuildInput) ([]string, error) { - events = append(events, "build") - if input.Apply.InjectedEnv["TOKEN"] != "secret" || input.Apply.Resolved.Name != "prod" { - t.Fatalf("build input = %#v", input) - } - return []string{"pnpm run build"}, nil - }} - provider := providerStub{id: workspace.DeployBackendVercel, apply: func( - _ context.Context, input deployport.ApplyInput, - ) (*deployport.ApplyResult, error) { - events = append(events, "apply") - if input.Environment != "staging" || input.InjectedEnv["TOKEN"] != "secret" || - input.InjectedEnvSource != "dotenv" { - t.Fatalf("apply input = %#v", input) - } - return &deployport.ApplyResult{Schema: "deploy/test", CommandLines: []string{"vercel deploy"}}, nil - }} - service, err := NewService( - catalog.Builtin(), deployport.MustRegistry(provider), resolver, - secrets.MustRegistry(loaderStub{id: "dotenv", vars: map[string]string{"TOKEN": "secret"}}), - builder, - ) - if err != nil { - t.Fatal(err) - } - observer := &observerStub{} - results, err := service.Execute(context.Background(), ExecuteRequest{ - ProjectRoot: root, Manifest: manifest, Targets: []Target{target}, - Profile: "prod", EnvProvider: "dotenv", Environment: "staging", DryRun: true, - }, observer) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(events, []string{"profile", "build", "apply"}) { - t.Fatalf("workflow events = %#v", events) - } - if !reflect.DeepEqual(observer.events, []string{"start:web", "complete:web"}) { - t.Fatalf("observer events = %#v", observer.events) - } - if len(results) != 1 || !reflect.DeepEqual(results[0].BuildCommandLines, []string{"pnpm run build"}) { - t.Fatalf("results = %#v", results) - } - if environment, err := readDeployEnvironment(manifest.Projects[0].Domains.Deploy); err != nil || environment != "staging" { - t.Fatalf("deploy environment = %q, %v", environment, err) - } -} - -func TestResolveProfileTurnsMissingConfigurationIntoNil(t *testing.T) { - missing := cliErrors.New(cliErrors.PROFILE_NONE_CONFIGURED, "missing") - service, err := NewService( - catalog.Builtin(), deployport.MustRegistry(providerStub{id: "vercel"}), - profileResolverStub{err: missing}, secrets.MustRegistry(), builderStub{}, - ) - if err != nil { - t.Fatal(err) - } - resolved, err := service.ResolveProfile("", &workspace.Manifest{}, "", Target{Backend: "vercel"}) - if err != nil || resolved != nil { - t.Fatalf("ResolveProfile() = %#v, %v", resolved, err) - } - - other := errors.New("read failed") - service.profiles = profileResolverStub{err: other} - if _, err := service.ResolveProfile("", &workspace.Manifest{}, "", Target{Backend: "vercel"}); !errors.Is(err, other) { - t.Fatalf("ResolveProfile() error = %v", err) - } -} - -func TestExecuteUsesSamePerTargetEnvironmentForProfilesAndSecretInjection(t *testing.T) { - root := t.TempDir() - previewConfig, err := json.Marshal(map[string]string{"env": "preview"}) - if err != nil { - t.Fatal(err) - } - manifest := &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "ws-demo", Name: "demo"}, - Environments: &workspace.Environments{Names: []string{"dev", "preview", "prod"}, Default: "dev"}, - Domains: &workspace.WorkspaceDomains{ - Env: &workspace.BackendRef{Kind: workspace.EnvBackendDotenv}, - }, - Projects: []workspace.ManifestProject{ - { - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendVercel, Config: previewConfig, - }}, - }, - { - Name: "api", RelativeDir: "services/api", Toolchain: "go", - Domains: &workspace.ProjectDomains{Deploy: &workspace.ProjectDeployBackend{ - Kind: workspace.DeployBackendVercel, - }}, - }, - }, - } - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - - var profileEnvironments []string - resolver := profileResolverStub{resolve: func(input profile.ResolveInput) (*profile.Resolved, error) { - profileEnvironments = append(profileEnvironments, input.Environment) - return &profile.Resolved{Name: "connection-" + input.Environment}, nil - }} - loader := &recordingLoader{ - id: "dotenv", vars: map[string]string{"TOKEN": "secret"}, - } - service, err := NewService( - catalog.Builtin(), - deployport.MustRegistry(providerStub{id: workspace.DeployBackendVercel}), - resolver, - secrets.MustRegistry(loader), - builderStub{}, - ) - if err != nil { - t.Fatal(err) - } - targets := []Target{ - manifestProjectTarget(root, &manifest.Projects[0], workspace.DeployBackendVercel), - manifestProjectTarget(root, &manifest.Projects[1], workspace.DeployBackendVercel), - } - if _, err := service.Execute(context.Background(), ExecuteRequest{ - ProjectRoot: root, - Manifest: manifest, - Targets: targets, - EnvProvider: workspace.EnvBackendDotenv, - DryRun: true, - }, nil); err != nil { - t.Fatal(err) - } - want := []string{"preview", "prod"} - if !reflect.DeepEqual(profileEnvironments, want) { - t.Fatalf("profile environments = %#v; want %#v", profileEnvironments, want) - } - if !reflect.DeepEqual(loader.environments, want) { - t.Fatalf("injection environments = %#v; want %#v", loader.environments, want) - } -} diff --git a/packages/cli/internal/application/deployment/targets.go b/packages/cli/internal/application/deployment/targets.go deleted file mode 100644 index 3a0debb9..00000000 --- a/packages/cli/internal/application/deployment/targets.go +++ /dev/null @@ -1,109 +0,0 @@ -package deployment - -import ( - "path/filepath" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -// Target is one project deployment job resolved from the workspace manifest. -type Target struct { - Project workspace.Project - Backend string - Toolchain string - TemplateID string - PackageManager string -} - -func configuredTargets(projectRoot string, manifest *workspace.Manifest) ([]Target, error) { - if !workspace.HasManifest(projectRoot) { - return nil, cliErrors.New(cliErrors.NOT_ONE_PROJECT, - "未检测到 One CLI 项目,请在工作区根目录执行。") - } - if manifest == nil { - return nil, cliErrors.New(cliErrors.ONE_CLI_ERROR, "deploy manifest 不能为空") - } - out := make([]Target, 0, len(manifest.Projects)) - for index := range manifest.Projects { - project := &manifest.Projects[index] - selection := workspace.DeployForProject(manifest, project.Name) - if selection.Backend == "" { - continue - } - out = append(out, manifestProjectTarget(projectRoot, project, selection.Backend)) - } - return out, nil -} - -func manifestProjectTarget(root string, project *workspace.ManifestProject, backend string) Target { - if project == nil { - return Target{} - } - return Target{ - Project: workspace.Project{ - Name: project.Name, RelativeDir: project.RelativeDir, - TargetDir: filepath.Join(root, filepath.FromSlash(project.RelativeDir)), - Toolchain: project.Toolchain, PackageManager: project.PackageManager, TemplateID: project.TemplateID, - }, - Backend: backend, Toolchain: project.Toolchain, - TemplateID: project.TemplateID, PackageManager: project.PackageManager, - } -} - -func findManifestProject(manifest *workspace.Manifest, selector string) *workspace.ManifestProject { - if manifest == nil { - return nil - } - selector = strings.TrimSpace(selector) - if selector == "" { - return nil - } - for index := range manifest.Projects { - if manifest.Projects[index].Name == selector { - return &manifest.Projects[index] - } - } - pathSelector := strings.TrimSuffix(strings.TrimPrefix(selector, "./"), "/") - pathSelector = workspace.ToPosixPath(pathSelector) - for index := range manifest.Projects { - if manifest.Projects[index].RelativeDir == pathSelector { - return &manifest.Projects[index] - } - } - return nil -} - -func templateForProject(registry *template.Registry, project *workspace.ManifestProject) *template.Template { - if registry == nil || project == nil { - return nil - } - for index := range registry.Templates { - if registry.Templates[index].ID == project.TemplateID { - return ®istry.Templates[index] - } - } - return nil -} - -func compatibleBackends(backendCatalog *catalog.Catalog, projectTemplate *template.Template) []string { - if backendCatalog == nil || projectTemplate == nil || projectTemplate.Compat == nil { - return nil - } - registered := map[string]bool{} - for _, backend := range backendCatalog.ForDomain(catalog.DomainDeploy) { - if backend.Has(catalog.CapabilityDeploy) { - registered[backend.ID.Name] = true - } - } - result := make([]string, 0, len(projectTemplate.Compat["deploy"])) - for _, id := range projectTemplate.Compat["deploy"] { - if registered[id] { - result = append(result, id) - } - } - return result -} diff --git a/packages/cli/internal/application/deployment/targets_test.go b/packages/cli/internal/application/deployment/targets_test.go deleted file mode 100644 index 9d684dd4..00000000 --- a/packages/cli/internal/application/deployment/targets_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package deployment - -import ( - "path/filepath" - "reflect" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestConfiguredTargetsUsesManifestDeploymentState(t *testing.T) { - root := t.TempDir() - manifest := &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "web", RelativeDir: "apps/web", Toolchain: "node", PackageManager: "pnpm", Domains: &workspace.ProjectDomains{ - Deploy: &workspace.ProjectDeployBackend{Kind: workspace.DeployBackendVercel}, - }}, - {Name: "library", RelativeDir: "packages/library", Toolchain: "go"}, - }} - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - targets, err := configuredTargets(root, manifest) - if err != nil { - t.Fatal(err) - } - if len(targets) != 1 || targets[0].Project.TargetDir != filepath.Join(root, "apps", "web") || - targets[0].Backend != workspace.DeployBackendVercel { - t.Fatalf("targets = %#v", targets) - } -} - -func TestCompatibleProvidersIntersectsTemplateAndCatalog(t *testing.T) { - projectTemplate := &template.Template{Compat: map[string][]string{ - "deploy": {"vercel", "not-registered", "cloudflare"}, - }} - got := compatibleBackends(catalog.Builtin(), projectTemplate) - if !reflect.DeepEqual(got, []string{"vercel", "cloudflare"}) { - t.Fatalf("compatibleBackends() = %#v", got) - } -} diff --git a/packages/cli/internal/application/deployment/workflow.go b/packages/cli/internal/application/deployment/workflow.go deleted file mode 100644 index 22142b36..00000000 --- a/packages/cli/internal/application/deployment/workflow.go +++ /dev/null @@ -1,171 +0,0 @@ -package deployment - -import ( - "context" - "fmt" - "io" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" -) - -// ProfileFallback lets a transport offer an interactive credential flow when -// normal profile resolution finds no configured profile. -type ProfileFallback func(Target, *profile.Resolved) (*profile.Resolved, error) - -type ExecuteRequest struct { - ProjectRoot string - Manifest *workspace.Manifest - Targets []Target - Profile string - EnvProvider string - Environment string - DryRun bool - Stdout io.Writer - Stderr io.Writer - ProfileFallback ProfileFallback -} - -type TargetResult struct { - Target Target - Apply *deployport.ApplyResult - BuildCommandLines []string - Injection *deployport.InjectionResult -} - -// Observer is the presentation boundary for long-running deployment work. -// Application owns ordering; Cobra decides how progress and results render. -type Observer interface { - TargetStarted(Target) - TargetCompleted(TargetResult) error -} - -// Execute runs the complete non-interactive deployment workflow for each -// target: environment policy, profile resolution, secret injection, build, -// provider dispatch, and result publication. -func (s *Service) Execute( - ctx context.Context, - request ExecuteRequest, - observer Observer, -) ([]TargetResult, error) { - if request.Manifest == nil { - return nil, fmt.Errorf("application: deploy manifest is required") - } - if err := applyEnvOverride(request.Manifest, request.Environment); err != nil { - return nil, err - } - if err := validateProjectEnvironments(request.Manifest); err != nil { - return nil, err - } - envProvider, err := resolveEnvProvider(request.Manifest, request.EnvProvider) - if err != nil { - return nil, err - } - - results := make([]TargetResult, 0, len(request.Targets)) - for _, target := range request.Targets { - if observer != nil { - observer.TargetStarted(target) - } - effectiveEnvironment := effectiveDeployEnvironment( - request.Manifest, target.Project.Name, request.Environment, - ) - resolved, err := s.resolveProfile( - request.ProjectRoot, request.Manifest, request.Profile, effectiveEnvironment, target, - ) - if err != nil { - return results, err - } - if request.ProfileFallback != nil { - resolved, err = request.ProfileFallback(target, resolved) - if err != nil { - return results, err - } - } - input := deployport.ApplyInput{ - ProjectRoot: request.ProjectRoot, - Project: target.Project, - Toolchain: target.Toolchain, - Environment: effectiveEnvironment, - Manifest: request.Manifest, - Resolved: resolved, - DryRun: request.DryRun, - Stdout: request.Stdout, - Stderr: request.Stderr, - } - injection, err := deployport.LoadInjectionEnv(ctx, input, deployport.LoadInjectionOptions{ - Loaders: s.loaders, LoaderID: envProvider, EnvName: effectiveEnvironment, - }) - if err != nil { - return results, err - } - if injection != nil { - input.InjectedEnv = injection.Vars - input.InjectedEnvSource = injection.Source - } - buildLines, err := s.builder.Build(ctx, deployport.BuildInput{ - Apply: input, Backend: target.Backend, - Toolchain: target.Toolchain, PackageManager: target.PackageManager, - }) - if err != nil { - return results, err - } - applied, err := s.apply(ctx, target.Backend, input) - if err != nil { - return results, err - } - if applied == nil { - continue - } - result := TargetResult{ - Target: target, Apply: applied, - BuildCommandLines: buildLines, Injection: injection, - } - results = append(results, result) - if observer != nil { - if err := observer.TargetCompleted(result); err != nil { - return results, err - } - } - } - return results, nil -} - -func (s *Service) ResolveProfile( - projectRoot string, - manifest *workspace.Manifest, - profileFlag string, - target Target, -) (*profile.Resolved, error) { - return s.resolveProfile( - projectRoot, - manifest, - profileFlag, - deployProfileEnvironment(manifest, target.Project.Name), - target, - ) -} - -func (s *Service) resolveProfile( - projectRoot string, - manifest *workspace.Manifest, - profileFlag, environment string, - target Target, -) (*profile.Resolved, error) { - environment = workspace.ProfileBindingEnvironment(manifest, environment) - resolved, err := s.profiles.Resolve(profile.ResolveInput{ - Domain: profile.DomainDeploy, Backend: target.Backend, - FlagOverride: profileFlag, WorkspaceID: workspace.WorkspaceID(manifest), - WorkspaceRoot: projectRoot, ProjectName: target.Project.Name, - Environment: environment, - }) - if err != nil { - if coded, ok := err.(interface{ ErrorCode() string }); ok && - coded.ErrorCode() == "PROFILE_NONE_CONFIGURED" { - return nil, nil - } - return nil, err - } - return resolved, nil -} diff --git a/packages/cli/internal/application/execution/operation.go b/packages/cli/internal/application/execution/operation.go index 0cbe8143..aebcf4be 100644 --- a/packages/cli/internal/application/execution/operation.go +++ b/packages/cli/internal/application/execution/operation.go @@ -2,22 +2,22 @@ package execution import ( "encoding/json" - "fmt" "os" "path/filepath" "runtime" - "strings" + + "gopkg.in/yaml.v3" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "gopkg.in/yaml.v3" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // OperationArgs resolves the live source command, never a copy in generated TOML. func OperationArgs(w Workspace, selector, operation string) ([]string, error) { p, ok := w.Project(selector) if !ok { - return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, "Unknown project: "+selector) + return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, i18n.Tf("workspace.unknown_project", selector)) } if operation == "dev" { command := workspace.ProjectDev(w.Manifest(), p.Name) @@ -33,6 +33,12 @@ func OperationArgs(w Workspace, selector, operation string) ([]string, error) { } return []string{"sh", "-c", command}, nil } + return ProjectOperationArgs(w.Root(), *p, operation) +} + +// ProjectOperationArgs resolves a task from the project's current source files. +// It is shared by execution and the Dashboard and never installs or runs tools. +func ProjectOperationArgs(root string, p workspace.Project, operation string) ([]string, error) { if operation != "build" && operation != "test" && operation != "lint" { return nil, missingOperation(p.Name, operation) } @@ -47,18 +53,9 @@ func OperationArgs(w Workspace, selector, operation string) ([]string, error) { if err = json.Unmarshal(raw, &pkg); err != nil { return nil, err } - manager := p.PackageManager - if rootPkg, err := workspace.ReadPackageJSON(w.Root()); err == nil && rootPkg != nil && rootPkg.PackageManager != "" { - manager = rootPkg.PackageManager - } - manager, _, _ = strings.Cut(manager, "@") - if manager == "" { - manager = "pnpm" - } - switch manager { - case "pnpm", "npm", "yarn", "bun": - default: - return nil, fmt.Errorf("unsupported package manager %q", manager) + manager, err := workspace.ResolvePackageManager(root, p.PackageManager) + if err != nil { + return nil, err } if pkg.Scripts[operation] == "" { return nil, missingOperation(p.Name, operation) @@ -84,7 +81,7 @@ func OperationArgs(w Workspace, selector, operation string) ([]string, error) { } switch operation { case "build": - return []string{"go", "build", "./..."}, nil + return nil, i18n.Errorf("build.taskfile_required", p.Name) case "test": return []string{"go", "test", "./..."}, nil case "lint": @@ -95,5 +92,5 @@ func OperationArgs(w Workspace, selector, operation string) ([]string, error) { } func missingOperation(project, operation string) error { - return cliErrors.New(cliErrors.RUNTIME_TASK_NOT_FOUND, fmt.Sprintf("Project %s has no %s task.", project, operation)) + return cliErrors.New(cliErrors.RUNTIME_TASK_NOT_FOUND, i18n.Tf("task.operation_missing", project, operation)) } diff --git a/packages/cli/internal/application/execution/selection.go b/packages/cli/internal/application/execution/selection.go new file mode 100644 index 00000000..4780493b --- /dev/null +++ b/packages/cli/internal/application/execution/selection.go @@ -0,0 +1,40 @@ +package execution + +import ( + "strings" + + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +// SelectProjects resolves names and paths once, preserving selection order. +// An empty selection denotes all projects. Legacy -p can alias one positional. +func (w Workspace) SelectProjects(args []string, legacy string) ([]string, error) { + if legacy != "" { + if len(args) > 1 { + return nil, i18n.Errorf("task.selector_conflict") + } + if len(args) == 1 { + a, aOK := w.Project(args[0]) + b, bOK := w.Project(legacy) + if !aOK || !bOK || a.Name != b.Name { + return nil, i18n.Errorf("task.selector_mismatch") + } + } else { + args = []string{legacy} + } + } + var names []string + seen := map[string]bool{} + for _, selector := range args { + p, ok := w.Project(strings.TrimSpace(selector)) + if !ok { + return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, i18n.Tf("workspace.unknown_project", selector)).WithContext(map[string]any{"selector": selector, "available_projects": w.ProjectNames()}) + } + if !seen[p.Name] { + names = append(names, p.Name) + seen[p.Name] = true + } + } + return names, nil +} diff --git a/packages/cli/internal/application/manifest/service.go b/packages/cli/internal/application/manifest/service.go index f4936e9f..a9ec76a9 100644 --- a/packages/cli/internal/application/manifest/service.go +++ b/packages/cli/internal/application/manifest/service.go @@ -6,12 +6,11 @@ import ( "context" "encoding/json" "fmt" - "sort" + "net/url" "strings" "sync" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" ) @@ -60,18 +59,6 @@ type ProjectEnvironmentPatch struct { Disabled bool `json:"disabled"` } -type ProjectContainerPatch struct { - Enabled bool `json:"enabled"` - Backend string `json:"backend"` - Image string `json:"image"` - Namespace string `json:"namespace"` -} - -type ProjectDeployPatch struct { - Backend string `json:"backend"` - Config map[string]any `json:"config"` -} - // ProjectManifestPatch is intentionally a whitelist rather than a partial // Manifest. Browser clients can only update the user-facing project settings // represented here; identity, paths, toolchains and unknown backend config @@ -80,12 +67,13 @@ type ProjectManifestPatch struct { Project string `json:"project"` General *ProjectGeneralPatch `json:"general,omitempty"` Environment *ProjectEnvironmentPatch `json:"environment,omitempty"` - Container *ProjectContainerPatch `json:"container,omitempty"` - Deploy *ProjectDeployPatch `json:"deploy,omitempty"` } type WorkspaceEnvironmentPatch struct { - Backend string `json:"backend"` + Backend string `json:"backend"` + ProjectID *string `json:"projectId,omitempty"` + ProjectName *string `json:"projectName,omitempty"` + SiteURL *string `json:"siteUrl,omitempty"` } type WorkspaceManifestPatch struct { @@ -93,8 +81,9 @@ type WorkspaceManifestPatch struct { } type ApplyManifestInput struct { - Revision string `json:"revision"` - Changes []ProjectManifestPatch `json:"changes"` + Workspace *WorkspaceManifestPatch `json:"workspace,omitempty"` + Revision string `json:"revision"` + Changes []ProjectManifestPatch `json:"changes"` } type ApplyManifestResult struct { @@ -127,7 +116,8 @@ func (s *Service) ApplyManifestDraft( s.mu.Lock() defer s.mu.Unlock() - if strings.TrimSpace(input.Revision) == "" || len(input.Changes) == 0 { + hasWorkspaceChange := input.Workspace != nil && input.Workspace.Environment != nil + if strings.TrimSpace(input.Revision) == "" || (!hasWorkspaceChange && len(input.Changes) == 0) { return ApplyManifestResult{}, fmt.Errorf("%w: revision and at least one change are required", ErrInvalidInput) } manifest, currentRevision, err := workspacecore.ReadManifestSnapshot(root) @@ -138,11 +128,19 @@ func (s *Service) ApplyManifestDraft( return ApplyManifestResult{}, &ManifestConflict{Expected: input.Revision, Current: currentRevision} } + if hasWorkspaceChange { + if err := applyWorkspaceEnvironmentPatch(manifest, input.Workspace.Environment); err != nil { + return ApplyManifestResult{}, err + } + } applied, err := s.applyProjectChanges(ctx, manifest, input.Changes) if err != nil { return ApplyManifestResult{}, err } + if hasWorkspaceChange { + applied++ + } if err := workspacecore.WriteManifest(root, manifest); err != nil { return ApplyManifestResult{}, err } @@ -211,6 +209,36 @@ func applyWorkspaceEnvironmentPatch( if manifest.Domains.Env == nil { manifest.Domains.Env = &workspacecore.BackendRef{} } + if patch.ProjectID != nil { + if backend != workspacecore.EnvBackendInfisical { + return fmt.Errorf("%w: project binding requires Infisical", ErrInvalidInput) + } + if strings.TrimSpace(*patch.ProjectID) == "" { + return fmt.Errorf("%w: projectId is required", ErrInvalidInput) + } + config := map[string]any{} + if len(manifest.Domains.Env.Config) > 0 { + if err := json.Unmarshal(manifest.Domains.Env.Config, &config); err != nil { + return err + } + } + config["projectId"] = *patch.ProjectID + if patch.ProjectName != nil { + config["projectName"] = *patch.ProjectName + } + if patch.SiteURL != nil { + u, err := url.Parse(*patch.SiteURL) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1"))) { + return fmt.Errorf("%w: invalid Infisical instance URL", ErrInvalidInput) + } + config["siteUrl"] = u.String() + } + data, err := json.Marshal(config) + if err != nil { + return err + } + manifest.Domains.Env.Config = data + } manifest.Domains.Env.Kind = backend return nil } @@ -220,18 +248,6 @@ func (s *Service) applyProjectChanges( manifest *workspacecore.Manifest, changes []ProjectManifestPatch, ) (int, error) { - var registry *template.Registry - var err error - for _, change := range changes { - if change.Deploy != nil { - registry, err = template.Fetch(ctx, "") - if err != nil { - return 0, err - } - break - } - } - seen := make(map[string]struct{}, len(changes)) applied := 0 for _, change := range changes { @@ -247,7 +263,7 @@ func (s *Service) applyProjectChanges( if project == nil { return 0, fmt.Errorf("%w: %s", ErrProjectNotFound, name) } - if change.General == nil && change.Environment == nil && change.Container == nil && change.Deploy == nil { + if change.General == nil && change.Environment == nil { return 0, fmt.Errorf("%w: project %q has no changes", ErrInvalidInput, name) } @@ -278,18 +294,6 @@ func (s *Service) applyProjectChanges( } applied++ } - if change.Container != nil { - if err := s.applyContainerPatch(project, change.Container); err != nil { - return 0, err - } - applied++ - } - if change.Deploy != nil { - if err := s.applyDeployPatch(manifest, project, change.Deploy, registry); err != nil { - return 0, err - } - applied++ - } } return applied, nil } @@ -320,181 +324,3 @@ func unsafeSecretPath(value string) bool { } return false } - -func (s *Service) applyContainerPatch( - project *workspacecore.ManifestProject, - patch *ProjectContainerPatch, -) error { - ensureProjectDomains(project) - if !patch.Enabled { - project.Domains.Container = nil - return nil - } - backend := strings.TrimSpace(patch.Backend) - if _, ok := s.catalog.Lookup(catalog.DomainContainer, backend); !ok { - return fmt.Errorf("%w: unknown container backend %q", ErrInvalidInput, backend) - } - project.Domains.Container = &workspacecore.ProjectContainerOverride{ - Kind: backend, Image: strings.TrimSpace(patch.Image), Namespace: strings.TrimSpace(patch.Namespace), - } - return nil -} - -func (s *Service) applyDeployPatch( - manifest *workspacecore.Manifest, - project *workspacecore.ManifestProject, - patch *ProjectDeployPatch, - registry *template.Registry, -) error { - ensureProjectDomains(project) - backend := strings.TrimSpace(patch.Backend) - if backend == "" { - project.Domains.Deploy = nil - return nil - } - spec, ok := s.catalog.Lookup(catalog.DomainDeploy, backend) - if !ok || !spec.Project.Configurable { - return fmt.Errorf("%w: unknown or non-configurable deploy backend %q", ErrInvalidInput, backend) - } - compatible := projectCompatibleDeployTargets(registry, project.TemplateID) - if len(compatible) > 0 && !containsString(compatible, backend) { - return fmt.Errorf("%w: deploy backend %q is incompatible with project %q", ErrInvalidInput, backend, project.Name) - } - existing := projectDeployConfig(project) - if project.Domains.Deploy == nil || strings.TrimSpace(project.Domains.Deploy.Kind) != backend { - existing = nil - } - raw, err := mergeProjectConfig(existing, patch.Config, spec.Project.Fields, manifest) - if err != nil { - return fmt.Errorf("%w: project %q deploy config: %v", ErrInvalidInput, project.Name, err) - } - project.Domains.Deploy = &workspacecore.ProjectDeployBackend{Kind: backend, Config: raw} - return nil -} - -func projectCompatibleDeployTargets(registry *template.Registry, templateID string) []string { - if registry == nil { - return nil - } - for _, entry := range registry.Templates { - if entry.ID == templateID { - return append([]string(nil), entry.Compat[string(catalog.DomainDeploy)]...) - } - } - return nil -} - -func projectDeployConfig(project *workspacecore.ManifestProject) json.RawMessage { - if project.Domains == nil || project.Domains.Deploy == nil { - return nil - } - return project.Domains.Deploy.Config -} - -func mergeProjectConfig( - existing json.RawMessage, - input map[string]any, - fields []catalog.ProjectFieldSpec, - manifest *workspacecore.Manifest, -) (json.RawMessage, error) { - object := map[string]any{} - if len(existing) > 0 { - if err := json.Unmarshal(existing, &object); err != nil || object == nil { - return nil, fmt.Errorf("existing config is not an object") - } - } - allowed := make(map[string]catalog.ProjectFieldSpec, len(fields)) - for _, field := range fields { - allowed[field.Path] = field - } - flat := map[string]any{} - flattenConfig("", input, flat) - for path, value := range flat { - field, ok := allowed[path] - if !ok { - return nil, fmt.Errorf("field %q is not configurable", path) - } - text, ok := value.(string) - if !ok { - return nil, fmt.Errorf("field %q must be a string", path) - } - text = strings.TrimSpace(text) - if field.Required && text == "" { - return nil, fmt.Errorf("field %q is required", path) - } - if field.Type == catalog.ProjectFieldEnvironment && text != "" && !manifestHasEnvironment(manifest, text) { - return nil, fmt.Errorf("environment %q is not declared", text) - } - if text == "" { - deleteConfigPath(object, path) - } else { - setConfigPath(object, path, text) - } - } - if len(object) == 0 { - return nil, nil - } - return json.Marshal(object) -} - -func flattenConfig(prefix string, input map[string]any, out map[string]any) { - keys := make([]string, 0, len(input)) - for key := range input { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - path := key - if prefix != "" { - path = prefix + "/" + key - } - if child, ok := input[key].(map[string]any); ok { - flattenConfig(path, child, out) - continue - } - out[path] = input[key] - } -} - -func setConfigPath(object map[string]any, path string, value string) { - parts := strings.Split(path, "/") - current := object - for _, part := range parts[:len(parts)-1] { - next, ok := current[part].(map[string]any) - if !ok { - next = map[string]any{} - current[part] = next - } - current = next - } - current[parts[len(parts)-1]] = value -} - -func deleteConfigPath(object map[string]any, path string) { - parts := strings.Split(path, "/") - current := object - for _, part := range parts[:len(parts)-1] { - next, ok := current[part].(map[string]any) - if !ok { - return - } - current = next - } - delete(current, parts[len(parts)-1]) -} - -func manifestHasEnvironment(manifest *workspacecore.Manifest, value string) bool { - if manifest == nil || manifest.Environments == nil { - return containsString(workspacecore.DefaultEnvironments, value) - } - return containsString(manifest.Environments.Names, value) -} - -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} diff --git a/packages/cli/internal/application/manifest/service_test.go b/packages/cli/internal/application/manifest/service_test.go index e5cb6132..b2c8048c 100644 --- a/packages/cli/internal/application/manifest/service_test.go +++ b/packages/cli/internal/application/manifest/service_test.go @@ -33,10 +33,6 @@ func seedManifest(t *testing.T) (string, *Service, string) { Domains: &workspacecore.ProjectDomains{ Dev: &workspacecore.ProjectDevOverride{Command: "pnpm dev"}, Env: &workspacecore.ProjectEnvOverride{Path: "/apps/web", Inherits: &value, Keys: []string{"API_URL"}}, - Container: &workspacecore.ProjectContainerOverride{ - Kind: "docker", Image: "web:latest", Namespace: "one", - }, - Deploy: &workspacecore.ProjectDeployBackend{Kind: "vercel", Config: config}, }, }}, } @@ -62,15 +58,12 @@ func TestApplyManifestDraftPublishesAllowlistedFieldsAtomically(t *testing.T) { Project: "web", General: &ProjectGeneralPatch{BuildVersion: "v2.1.0", DevCommand: "pnpm start"}, Environment: &ProjectEnvironmentPatch{Path: "/frontend", Inherits: false, Disabled: true}, - Container: &ProjectContainerPatch{ - Enabled: true, Backend: "ghcr", Image: "ghcr.io/acme/web:2.1.0", Namespace: "acme", - }, }}, }) if err != nil { t.Fatal(err) } - if result.Applied != 3 || result.Revision == revision { + if result.Applied != 2 || result.Revision == revision { t.Fatalf("result = %#v", result) } manifest, err := workspacecore.ReadManifest(root) @@ -87,9 +80,6 @@ func TestApplyManifestDraftPublishesAllowlistedFieldsAtomically(t *testing.T) { if len(project.Domains.Env.Keys) != 1 || project.Domains.Env.Keys[0] != "API_URL" { t.Fatalf("environment keys were not preserved: %#v", project.Domains.Env.Keys) } - if project.Domains.Container.Kind != "ghcr" || project.Domains.Container.Namespace != "acme" { - t.Fatalf("container patch = %#v", project.Domains.Container) - } } func TestApplyManifestDraftRejectsStaleRevisionWithoutWriting(t *testing.T) { @@ -123,10 +113,8 @@ func TestApplyManifestDraftRejectsUnknownFieldsAndUnsafeValues(t *testing.T) { change ProjectManifestPatch }{ { - name: "unknown container backend", - change: ProjectManifestPatch{Project: "web", Container: &ProjectContainerPatch{ - Enabled: true, Backend: "unknown", - }}, + name: "empty changes", + change: ProjectManifestPatch{Project: "web"}, }, { name: "unsafe environment path", @@ -134,12 +122,6 @@ func TestApplyManifestDraftRejectsUnknownFieldsAndUnsafeValues(t *testing.T) { Path: "../../shared", Inherits: true, }}, }, - { - name: "undeclared deploy config", - change: ProjectManifestPatch{Project: "web", Deploy: &ProjectDeployPatch{ - Backend: "vercel", Config: map[string]any{"apiToken": "must-not-enter-manifest"}, - }}, - }, } { t.Run(test.name, func(t *testing.T) { root, service, revision := seedManifest(t) @@ -250,3 +232,37 @@ func TestPreviewManifestDraftRejectsStaleRevisionWithoutWriting(t *testing.T) { t.Fatal("stale preview changed the manifest") } } + +func TestWorkspaceBindingAndProjectChangesPublishTogether(t *testing.T) { + root, service, revision := seedManifest(t) + id, name, site := "remote-project", "Shared", "https://app.infisical.com" + binding := &WorkspaceManifestPatch{Environment: &WorkspaceEnvironmentPatch{Backend: "infisical", ProjectID: &id, ProjectName: &name, SiteURL: &site}} + before, _ := os.ReadFile(workspacecore.ManifestPath(root)) + _, err := service.ApplyManifestDraft(context.Background(), root, ApplyManifestInput{Revision: revision, Workspace: binding, Changes: []ProjectManifestPatch{{Project: "missing", General: &ProjectGeneralPatch{BuildVersion: "2.0.0"}}}}) + if err == nil { + t.Fatal("invalid project accepted") + } + after, _ := os.ReadFile(workspacecore.ManifestPath(root)) + if string(before) != string(after) { + t.Fatal("failed project patch partially wrote workspace binding") + } + _, err = service.PreviewManifestDraft(context.Background(), root, PreviewManifestInput{Revision: revision, Workspace: binding}) + if err != nil { + t.Fatal(err) + } + _, err = service.ApplyManifestDraft(context.Background(), root, ApplyManifestInput{Revision: revision, Workspace: binding}) + if err != nil { + t.Fatal(err) + } + manifest, err := workspacecore.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + var config map[string]string + if err = json.Unmarshal(manifest.Domains.Env.Config, &config); err != nil { + t.Fatal(err) + } + if config["projectId"] != id || config["siteUrl"] != site || config["projectName"] != name { + t.Fatalf("binding: %#v", config) + } +} diff --git a/packages/cli/internal/application/workspace/environment_settings.go b/packages/cli/internal/application/workspace/environment_settings.go new file mode 100644 index 00000000..ca1f4439 --- /dev/null +++ b/packages/cli/internal/application/workspace/environment_settings.go @@ -0,0 +1,64 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" +) + +type WorkspaceEnvironmentSettings struct { + Schema string `json:"schema"` + Revision string `json:"revision"` + Backend string `json:"backend"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + SiteURL string `json:"siteUrl"` +} + +func (s *Service) WorkspaceEnvironment(_ context.Context, root, environment string) (WorkspaceEnvironmentSettings, error) { + s.mu.RLock() + defer s.mu.RUnlock() + manifest, revision, e := workspacecore.ReadManifestSnapshot(root) + if e != nil { + return WorkspaceEnvironmentSettings{}, e + } + if _, e = validateEnvironment(manifest, environment); e != nil { + return WorkspaceEnvironmentSettings{}, e + } + result := WorkspaceEnvironmentSettings{Schema: "one-cli/workspace-environment/v1", Revision: revision, Backend: workspacecore.EnvBackend(manifest)} + if manifest.Domains != nil && manifest.Domains.Env != nil { + var cfg struct { + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + SiteURL string `json:"siteUrl"` + } + if e = json.Unmarshal(manifest.Domains.Env.Config, &cfg); len(manifest.Domains.Env.Config) > 0 && e != nil { + return result, e + } + result.ProjectID = cfg.ProjectID + result.ProjectName = cfg.ProjectName + result.SiteURL = cfg.SiteURL + } + return result, nil +} + +func validateEnvironment(_ *workspacecore.Manifest, requested string) (string, error) { + environment := strings.TrimSpace(requested) + if requested == "" { + return "", nil + } + if requested == environment && len(environment) <= 128 && environmentIDPattern.MatchString(environment) { + return environment, nil + } + return "", fmt.Errorf( + "%w: environment %q is not a safe environment id", + ErrInvalidInput, + environment, + ) +} + +var environmentIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) diff --git a/packages/cli/internal/application/workspace/project_build_settings_test.go b/packages/cli/internal/application/workspace/project_build_settings_test.go new file mode 100644 index 00000000..5a4a4f2a --- /dev/null +++ b/packages/cli/internal/application/workspace/project_build_settings_test.go @@ -0,0 +1,81 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "testing" + + catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" + workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" +) + +func TestProjectSettingsReadsLiveBuildTaskWithoutWriting(t *testing.T) { + for _, tc := range []struct { + name, toolchain, file, content, command, status string + }{ + {"node", "node", "package.json", `{"scripts":{"build":"echo build"}}`, "npm run build", "ready"}, + {"node without build", "node", "package.json", `{"scripts":{"dev":"vite"}}`, "", "missing"}, + {"invalid package", "node", "package.json", `{invalid`, "", "invalid"}, + {"missing package", "node", "", "", "", "invalid"}, + {"go", "go", "Taskfile.yml", "version: '3'\ntasks:\n build:\n cmds: ['go build ./...']\n", "task build", "ready"}, + {"go without build", "go", "Taskfile.yml", "version: '3'\ntasks: {}\n", "", "missing"}, + {"invalid taskfile", "go", "Taskfile.yml", "tasks: [", "", "invalid"}, + {"missing taskfile", "go", "", "", "", "invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := seedProjectSettingsWorkspace(t) + manifest, err := workspacecore.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + manifest.Projects[0].Toolchain = tc.toolchain + if err := workspacecore.WriteManifest(root, manifest); err != nil { + t.Fatal(err) + } + write := func(path, contents string) { + t.Helper() + target := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + } + // The workspace package manager takes precedence over the project's pnpm. + write("package.json", `{"packageManager":"npm@11.0.0"}`) + if tc.file != "" { + write(filepath.Join("apps/web", tc.file), tc.content) + } + before := snapshotWorkspaceTree(t, root) + service, err := NewService(catalog.Builtin()) + if err != nil { + t.Fatal(err) + } + settings, err := service.ProjectSettings(context.Background(), root, "web", "dev") + if err != nil { + t.Fatal(err) + } + source := "package.json#scripts.build" + if tc.toolchain == "go" { + source = "Taskfile.yml#tasks.build" + } + want := ProjectBuildSettings{Command: tc.command, Source: source, Status: tc.status} + if settings.Project.Build != want { + t.Fatalf("build = %#v, want %#v", settings.Project.Build, want) + } + assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) + if tc.name == "node" { + write("apps/web/package.json", `{"scripts":{}}`) + refreshed, err := service.ProjectSettings(context.Background(), root, "web", "dev") + if err != nil { + t.Fatal(err) + } + if refreshed.Project.Build.Status != "missing" || refreshed.Project.Build.Command != "" || refreshed.Revision != settings.Revision { + t.Fatalf("build did not refresh independently of manifest: %#v", refreshed) + } + } + }) + } +} diff --git a/packages/cli/internal/application/workspace/project_profile_bindings.go b/packages/cli/internal/application/workspace/project_profile_bindings.go deleted file mode 100644 index 0ddf0950..00000000 --- a/packages/cli/internal/application/workspace/project_profile_bindings.go +++ /dev/null @@ -1,177 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - "regexp" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// UpdateProjectProfileBinding changes only a machine-local profile choice. -// Project metadata and backend selection remain owned by one.manifest.json, -// which this application boundary treats as a read-only input. -func (s *Service) UpdateProjectProfileBinding( - ctx context.Context, - root, projectName, domainName, environment, profileName string, -) (ProjectSettings, error) { - s.mu.Lock() - defer s.mu.Unlock() - - manifest, project, err := readProject(root, projectName) - if err != nil { - return ProjectSettings{}, err - } - domain, backend, err := s.projectProfileBackend(manifest, project.Name, domainName) - if err != nil { - return ProjectSettings{}, err - } - environment, err = validateEnvironment(manifest, environment) - if err != nil { - return ProjectSettings{}, err - } - bindingEnvironment := workspacecore.ProfileBindingEnvironment(manifest, environment) - if err := s.changeProfileBinding( - manifest, root, project.Name, bindingEnvironment, domain, backend, profileName, - ); err != nil { - return ProjectSettings{}, err - } - return s.projectSettings(ctx, root, project.Name, environment) -} - -func readProject( - root, projectName string, -) (*workspacecore.Manifest, *workspacecore.ManifestProject, error) { - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - return nil, nil, err - } - project := findProject(manifest, strings.TrimSpace(projectName)) - if project == nil { - return nil, nil, fmt.Errorf("%w: %s", ErrProjectNotFound, projectName) - } - return manifest, project, nil -} - -func (s *Service) projectProfileBackend( - manifest *workspacecore.Manifest, - projectName, domainName string, -) (profile.Domain, string, error) { - var domain profile.Domain - var backend string - switch strings.TrimSpace(domainName) { - case string(profile.DomainEnv): - domain = profile.DomainEnv - backend = workspacecore.EnvBackend(manifest) - case string(profile.DomainDeploy): - domain = profile.DomainDeploy - backend = effectiveDeployBackend(manifest, projectName) - case string(profile.DomainContainer): - domain = profile.DomainContainer - backend = workspacecore.ContainerKindForProject(manifest, projectName) - default: - return "", "", fmt.Errorf( - "%w: profile domain must be env, deploy, or container", ErrInvalidInput, - ) - } - backend = strings.TrimSpace(backend) - if backend == "" { - return "", "", fmt.Errorf( - "%w: %s backend is not configured in one.manifest.json", ErrInvalidInput, domain, - ) - } - spec, ok := s.catalog.Lookup(catalog.Domain(domain), backend) - if !ok { - return "", "", fmt.Errorf( - "%w: unknown %s backend %q in one.manifest.json", ErrInvalidInput, domain, backend, - ) - } - if !spec.Profile.Configurable { - return "", "", fmt.Errorf( - "%w: backend %s/%s does not accept a profile", ErrInvalidInput, domain, backend, - ) - } - return domain, backend, nil -} - -var environmentIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) - -func validateEnvironment(_ *workspacecore.Manifest, requested string) (string, error) { - environment := strings.TrimSpace(requested) - if requested == "" { - return "", nil - } - if requested == environment && len(environment) <= 128 && environmentIDPattern.MatchString(environment) { - return environment, nil - } - return "", fmt.Errorf( - "%w: environment %q is not a safe environment id", - ErrInvalidInput, - environment, - ) -} - -func (s *Service) changeProfileBinding( - manifest *workspacecore.Manifest, - root, projectName, environment string, - domain profile.Domain, - backend, requested string, -) error { - if s.profiles == nil { - return fmt.Errorf("workspace: profile service is unavailable") - } - name := strings.TrimSpace(requested) - if name != "" { - if _, err := s.profiles.Resolve(profile.ResolveInput{ - Domain: domain, - Backend: backend, - FlagOverride: name, - WorkspaceID: workspacecore.WorkspaceID(manifest), - WorkspaceRoot: root, - ProjectName: projectName, - Environment: environment, - SkipDefault: true, - }); err != nil { - return fmt.Errorf("%w: profile %q is not usable: %v", ErrInvalidInput, name, err) - } - } - - if environment != "" { - if name == "" { - return s.profiles.UnbindEnvironmentProfile( - root, projectName, environment, domain, backend, - ) - } - workspaceID, workspaceName := manifestIdentity(manifest) - return s.profiles.BindEnvironmentProfile( - workspaceID, workspaceName, root, projectName, environment, domain, backend, name, - ) - } - - workspaceID, workspaceName := manifestIdentity(manifest) - if workspaceID == "" { - if name == "" { - // A legacy manifest without workspace.id cannot have an addressable - // legacy binding. Treat explicit unbind as an idempotent no-op; never - // upgrade the manifest merely to manufacture an identity. - return nil - } - return fmt.Errorf("%w: workspace id is required to bind a profile", ErrInvalidInput) - } - if name == "" { - return s.profiles.UnbindWorkspaceProfile(workspaceID, projectName, domain, backend) - } - return s.profiles.BindWorkspaceProfile( - workspaceID, workspaceName, root, projectName, domain, backend, name, - ) -} - -func manifestIdentity(manifest *workspacecore.Manifest) (id, name string) { - if manifest == nil || manifest.Workspace == nil { - return "", "" - } - return strings.TrimSpace(manifest.Workspace.ID), strings.TrimSpace(manifest.Workspace.Name) -} diff --git a/packages/cli/internal/application/workspace/project_settings.go b/packages/cli/internal/application/workspace/project_settings.go index d616afde..f3d22300 100644 --- a/packages/cli/internal/application/workspace/project_settings.go +++ b/packages/cli/internal/application/workspace/project_settings.go @@ -1,17 +1,17 @@ package workspace import ( - "bytes" "context" - "encoding/json" + "errors" "fmt" + "path/filepath" "sort" "strings" - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" ) // ProjectSettingsSchema versions the safe, project-focused Dashboard @@ -35,48 +35,30 @@ type ProjectSettingsProject struct { PackageManager string `json:"packageManager,omitempty"` BuildVersion string `json:"buildVersion,omitempty"` DevCommand string `json:"devCommand,omitempty"` + Build ProjectBuildSettings `json:"build"` DefaultEnvironment string `json:"defaultEnvironment,omitempty"` AvailableEnvironments []string `json:"availableEnvironments"` Environment ProjectEnvironmentSettings `json:"environment"` - Container ProjectContainerSettings `json:"container"` - Deploy ProjectDeploySettings `json:"deploy"` } -type ProjectProfileRef struct { - Name string `json:"name"` - Source string `json:"source"` +// ProjectBuildSettings is a read-only projection of the live build task. +// Source is project-relative; Status is ready, missing, or invalid. +type ProjectBuildSettings struct { + Command string `json:"command,omitempty"` + Source string `json:"source,omitempty"` + Status string `json:"status"` } type ProjectEnvironmentSettings struct { - Backend string `json:"backend,omitempty"` - Path string `json:"path,omitempty"` - Inherits bool `json:"inherits"` - Disabled bool `json:"disabled"` - Keys []string `json:"keys"` - SelectedProfile string `json:"selectedProfile"` - Profile *ProjectProfileRef `json:"profile,omitempty"` + Backend string `json:"backend,omitempty"` + Path string `json:"path,omitempty"` + Inherits bool `json:"inherits"` + Disabled bool `json:"disabled"` + Keys []string `json:"keys"` } -type ProjectContainerSettings struct { - Enabled bool `json:"enabled"` - Backend string `json:"backend,omitempty"` - Image string `json:"image,omitempty"` - Namespace string `json:"namespace,omitempty"` - SelectedProfile string `json:"selectedProfile"` - Profile *ProjectProfileRef `json:"profile,omitempty"` -} - -type ProjectDeploySettings struct { - Backend string `json:"backend,omitempty"` - CompatibleTargets []string `json:"compatibleTargets"` - Config map[string]any `json:"config"` - SelectedProfile string `json:"selectedProfile"` - Profile *ProjectProfileRef `json:"profile,omitempty"` -} - -// ProjectSettings returns the manifest-owned settings for one project plus -// safe machine-profile references. Profile values are never copied into the -// response. +// ProjectSettings returns manifest-owned settings, the live build command, +// and environment metadata. Credential values never enter the response. func (s *Service) ProjectSettings( ctx context.Context, root, projectName, environment string, @@ -102,13 +84,7 @@ func (s *Service) projectSettings( if err != nil { return ProjectSettings{}, err } - registry, err := template.Fetch(ctx, "") - if err != nil { - return ProjectSettings{}, err - } - compatible := projectCompatibleDeployTargets(registry, project.TemplateID) environments, defaultEnvironment := projectEnvironments(manifest) - profileEnvironment := workspacecore.ProfileBindingEnvironment(manifest, environment) env := ProjectEnvironmentSettings{ Backend: strings.TrimSpace(workspacecore.EnvBackend(manifest)), @@ -124,69 +100,6 @@ func (s *Service) projectSettings( env.Keys = append([]string(nil), override.Keys...) sort.Strings(env.Keys) } - if env.Backend != "" { - env.Profile = s.resolveProfileRef( - manifest, root, profileEnvironment, project.Name, profile.DomainEnv, env.Backend, - ) - env.SelectedProfile, err = s.directProfileSelection( - root, project.Name, profileEnvironment, profile.DomainEnv, env.Backend, env.Profile, - ) - if err != nil { - return ProjectSettings{}, err - } - } - - containerEnabled, containerImage := workspacecore.ContainerForProject(manifest, project.Name) - container := ProjectContainerSettings{ - Enabled: containerEnabled, - Backend: workspacecore.ContainerKindForProject(manifest, project.Name), - Image: containerImage, - Namespace: workspacecore.ContainerNamespaceForProject(manifest, project.Name), - } - if container.Enabled && container.Backend != "" { - container.Profile = s.resolveProfileRef( - manifest, root, profileEnvironment, project.Name, profile.DomainContainer, container.Backend, - ) - container.SelectedProfile, err = s.directProfileSelection( - root, project.Name, profileEnvironment, profile.DomainContainer, container.Backend, - container.Profile, - ) - if err != nil { - return ProjectSettings{}, err - } - } - - deployBackend := effectiveDeployBackend(manifest, project.Name) - deployConfig := map[string]any{} - if deployBackend != "" { - spec, ok := s.catalog.Lookup(catalog.DomainDeploy, deployBackend) - if !ok { - return ProjectSettings{}, fmt.Errorf("workspace: unknown deploy backend %q in manifest", deployBackend) - } - deployConfig, err = safeProjectConfig( - workspacecore.DeployConfigRawForProject(manifest, project.Name), spec.Project.Fields, - ) - if err != nil { - return ProjectSettings{}, fmt.Errorf("workspace: project %q deploy config: %w", project.Name, err) - } - } - deploy := ProjectDeploySettings{ - Backend: deployBackend, - CompatibleTargets: compatible, - Config: deployConfig, - } - if deploy.Backend != "" { - deploy.Profile = s.resolveProfileRef( - manifest, root, profileEnvironment, project.Name, profile.DomainDeploy, deploy.Backend, - ) - deploy.SelectedProfile, err = s.directProfileSelection( - root, project.Name, profileEnvironment, profile.DomainDeploy, deploy.Backend, - deploy.Profile, - ) - if err != nil { - return ProjectSettings{}, err - } - } return ProjectSettings{ Schema: ProjectSettingsSchema, @@ -202,71 +115,38 @@ func (s *Service) projectSettings( PackageManager: project.PackageManager, BuildVersion: project.BuildVersion, DevCommand: workspacecore.ProjectDev(manifest, project.Name), + Build: projectBuildSettings(root, *project), DefaultEnvironment: defaultEnvironment, AvailableEnvironments: environments, Environment: env, - Container: container, - Deploy: deploy, }, }, nil } -func (s *Service) resolveProfileRef( - manifest *workspacecore.Manifest, - root, environment, projectName string, - domain profile.Domain, - backend string, -) *ProjectProfileRef { - if s.profiles == nil || strings.TrimSpace(backend) == "" { - return nil - } - resolved, err := s.profiles.Resolve(profile.ResolveInput{ - Domain: domain, - Backend: backend, - WorkspaceID: workspacecore.WorkspaceID(manifest), - WorkspaceRoot: root, - ProjectName: projectName, - Environment: environment, - }) - if err != nil || resolved == nil || strings.TrimSpace(resolved.Name) == "" { - return nil - } - return &ProjectProfileRef{Name: resolved.Name, Source: resolved.Source} -} - -func (s *Service) directProfileSelection( - root, projectName, environment string, - domain profile.Domain, - backend string, - effective *ProjectProfileRef, -) (string, error) { - if environment == "" { - directSource := workspaceDirectSource(environment) - if projectName != "" { - directSource = projectDirectSource(environment) +func projectBuildSettings(root string, project workspacecore.ManifestProject) ProjectBuildSettings { + build := ProjectBuildSettings{Status: "missing"} + switch project.Toolchain { + case "node": + build.Source = "package.json#scripts.build" + case "go": + build.Source = "Taskfile.yml#tasks.build" + } + args, err := execution.ProjectOperationArgs(root, workspacecore.Project{ + Name: project.Name, RelativeDir: project.RelativeDir, + TargetDir: filepath.Join(root, filepath.FromSlash(project.RelativeDir)), + Toolchain: project.Toolchain, PackageManager: project.PackageManager, + TemplateID: project.TemplateID, + }, "build") + if err != nil { + var taskError *output.Error + if !errors.As(err, &taskError) || taskError.Code != string(cliErrors.RUNTIME_TASK_NOT_FOUND) { + build.Status = "invalid" } - return directProfileName(effective, directSource), nil - } - if s.profiles == nil { - return "", nil - } - return s.profiles.EnvironmentProfileBinding( - root, projectName, environment, domain, backend, - ) -} - -func projectDirectSource(environment string) string { - if environment != "" { - return "workspace-project-environment" + return build } - return "workspace-project" -} - -func directProfileName(resolved *ProjectProfileRef, directSource string) string { - if resolved == nil || resolved.Source != directSource { - return "" - } - return resolved.Name + build.Command = strings.Join(args, " ") + build.Status = "ready" + return build } func projectKind(relativeDir string) string { @@ -281,22 +161,6 @@ func projectKind(relativeDir string) string { } } -func projectCompatibleDeployTargets(registry *template.Registry, templateID string) []string { - if registry == nil { - return []string{} - } - for _, entry := range registry.Templates { - if entry.ID == templateID { - out := append([]string(nil), entry.Compat[string(catalog.DomainDeploy)]...) - if out == nil { - return []string{} - } - return out - } - } - return []string{} -} - func projectEnvironments(manifest *workspacecore.Manifest) ([]string, string) { environments := append([]string(nil), workspacecore.DefaultEnvironments...) defaultEnvironment := "" @@ -311,79 +175,3 @@ func projectEnvironments(manifest *workspacecore.Manifest) ([]string, string) { } return environments, defaultEnvironment } - -func effectiveDeployBackend(manifest *workspacecore.Manifest, projectName string) string { - if selected := workspacecore.DeployForProject(manifest, projectName).Backend; selected != "" { - return strings.TrimSpace(selected) - } - if manifest != nil && manifest.Domains != nil && manifest.Domains.Deploy != nil { - return strings.TrimSpace(manifest.Domains.Deploy.Kind) - } - return "" -} - -// safeProjectConfig projects only catalog-declared fields out of a manifest -// config object. Unknown keys are omitted rather than reflected, which makes -// this read path safe even when a hand-edited manifest accidentally contains -// a token-like field. -func safeProjectConfig(raw json.RawMessage, fields []catalog.ProjectFieldSpec) (map[string]any, error) { - out := map[string]any{} - if len(bytes.TrimSpace(raw)) == 0 { - return out, nil - } - var object map[string]json.RawMessage - if err := json.Unmarshal(raw, &object); err != nil || object == nil || bytes.TrimSpace(raw)[0] != '{' { - if err == nil { - err = fmt.Errorf("must be a JSON object") - } - return nil, err - } - for _, field := range fields { - value, ok := rawValueAtPath(object, field.Path) - if !ok { - continue - } - var text string - if err := json.Unmarshal(value, &text); err != nil { - // A wrong-type hand edit is not safe form data; omit it without - // reflecting arbitrary nested JSON to the Dashboard. - continue - } - setValueAtPath(out, field.Path, text) - } - return out, nil -} - -func rawValueAtPath(object map[string]json.RawMessage, path string) (json.RawMessage, bool) { - parts := strings.Split(path, "/") - current := object - for index, part := range parts { - raw, ok := current[part] - if !ok { - return nil, false - } - if index == len(parts)-1 { - return raw, true - } - var child map[string]json.RawMessage - if err := json.Unmarshal(raw, &child); err != nil { - return nil, false - } - current = child - } - return nil, false -} - -func setValueAtPath(object map[string]any, path string, value any) { - parts := strings.Split(path, "/") - current := object - for _, part := range parts[:len(parts)-1] { - next, ok := current[part].(map[string]any) - if !ok { - next = map[string]any{} - current[part] = next - } - current = next - } - current[parts[len(parts)-1]] = value -} diff --git a/packages/cli/internal/application/workspace/project_settings_test.go b/packages/cli/internal/application/workspace/project_settings_test.go index ebe740fc..39cb9b07 100644 --- a/packages/cli/internal/application/workspace/project_settings_test.go +++ b/packages/cli/internal/application/workspace/project_settings_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "io/fs" "os" "path/filepath" @@ -12,144 +11,12 @@ import ( "testing" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" ) -type projectProfileAccessStub struct { - available map[string]struct{} - bindings map[string]string - defaults map[string]string - bindErr error - lastMode string -} - -func projectBindingKey( - root, projectName, environment string, - domain profile.Domain, - backend string, -) string { - return strings.Join([]string{root, projectName, environment, profile.SectionKey(domain, backend)}, "|") -} - -func (s *projectProfileAccessStub) Resolve(input profile.ResolveInput) (*profile.Resolved, error) { - section := profile.SectionKey(input.Domain, input.Backend) - name := strings.TrimSpace(input.FlagOverride) - source := "flag" - if name == "" && input.Environment != "" { - name = s.bindings[projectBindingKey( - input.WorkspaceRoot, input.ProjectName, input.Environment, input.Domain, input.Backend, - )] - if name != "" { - if input.ProjectName != "" { - source = "workspace-project-environment" - } else { - source = "workspace-environment" - } - } - } - if name == "" { - name = s.bindings[projectBindingKey( - input.WorkspaceID, input.ProjectName, "", input.Domain, input.Backend, - )] - if name != "" { - if input.ProjectName != "" { - source = "workspace-project" - } else { - source = "workspace" - } - } - } - if name == "" && !input.SkipDefault { - name = s.defaults[section] - source = "default" - } - if name == "" { - return nil, errors.New("profile not configured") - } - if _, ok := s.available[section+"/"+name]; !ok { - return nil, errors.New("profile not found") - } - return &profile.Resolved{ - Name: name, Source: source, - Profile: profile.Profile{Vercel: &profile.VercelProfile{ - Credentials: &profile.VercelCredentials{APIToken: "never-return-this-token"}, - }}, - }, nil -} - -func (s *projectProfileAccessStub) BindWorkspaceProfile( - workspaceID, _, _ string, - projectName string, - domain profile.Domain, - backend, name string, -) error { - if s.bindErr != nil { - return s.bindErr - } - s.lastMode = "legacy-bind" - s.bindings[projectBindingKey(workspaceID, projectName, "", domain, backend)] = name - return nil -} - -func (s *projectProfileAccessStub) UnbindWorkspaceProfile( - workspaceID, projectName string, - domain profile.Domain, - backend string, -) error { - if s.bindErr != nil { - return s.bindErr - } - s.lastMode = "legacy-unbind" - delete(s.bindings, projectBindingKey(workspaceID, projectName, "", domain, backend)) - return nil -} - -func (s *projectProfileAccessStub) BindEnvironmentProfile( - _, _, root, projectName, environment string, - domain profile.Domain, - backend, name string, -) error { - if s.bindErr != nil { - return s.bindErr - } - s.lastMode = "environment-bind" - s.bindings[projectBindingKey(root, projectName, environment, domain, backend)] = name - return nil -} - -func (s *projectProfileAccessStub) UnbindEnvironmentProfile( - root, projectName, environment string, - domain profile.Domain, - backend string, -) error { - if s.bindErr != nil { - return s.bindErr - } - s.lastMode = "environment-unbind" - delete(s.bindings, projectBindingKey(root, projectName, environment, domain, backend)) - return nil -} - -func (s *projectProfileAccessStub) EnvironmentProfileBinding( - root, projectName, environment string, - domain profile.Domain, - backend string, -) (string, error) { - return s.bindings[projectBindingKey(root, projectName, environment, domain, backend)], nil -} - func seedProjectSettingsWorkspace(t *testing.T) string { t.Helper() root := t.TempDir() - deployConfig, err := json.Marshal(map[string]any{ - "projectId": "prj_demo", - "env": "prod", - "apiToken": "must-not-leak", - }) - if err != nil { - t.Fatal(err) - } inherits := false manifest := &workspacecore.Manifest{ Version: workspacecore.ManifestVersion, @@ -165,11 +32,8 @@ func seedProjectSettingsWorkspace(t *testing.T) string { Env: &workspacecore.ProjectEnvOverride{ Path: "/apps/web", Inherits: &inherits, Keys: []string{"Z_KEY", "A_KEY"}, }, - Container: &workspacecore.ProjectContainerOverride{ - Kind: catalog.ContainerGHCR, Image: "ghcr.io/acme/web:1.2.3", Namespace: "acme", - }, - Deploy: &workspacecore.ProjectDeployBackend{Kind: catalog.DeployVercel, Config: deployConfig}, - Dev: &workspacecore.ProjectDevOverride{Command: "pnpm dev"}, + + Dev: &workspacecore.ProjectDevOverride{Command: "pnpm dev"}, }, }}, } @@ -182,24 +46,6 @@ func seedProjectSettingsWorkspace(t *testing.T) string { return root } -func projectProfileStub() *projectProfileAccessStub { - available := map[string]struct{}{} - for _, pair := range [][2]string{ - {profile.SectionKey(profile.DomainEnv, catalog.EnvInfisical), "work"}, - {profile.SectionKey(profile.DomainContainer, catalog.ContainerGHCR), "work"}, - {profile.SectionKey(profile.DomainDeploy, catalog.DeployVercel), "work"}, - } { - available[pair[0]+"/"+pair[1]] = struct{}{} - } - return &projectProfileAccessStub{ - available: available, - bindings: map[string]string{}, - defaults: map[string]string{ - profile.SectionKey(profile.DomainEnv, catalog.EnvInfisical): "work", - }, - } -} - func snapshotWorkspaceTree(t *testing.T, root string) map[string][]byte { t.Helper() result := map[string][]byte{} @@ -241,11 +87,7 @@ func assertWorkspaceTreeEqual(t *testing.T, got, want map[string][]byte) { func TestProjectSettingsReturnsEnvironmentAwareSafeProjection(t *testing.T) { root := seedProjectSettingsWorkspace(t) - profiles := projectProfileStub() - profiles.bindings[projectBindingKey( - root, "web", "staging", profile.DomainEnv, catalog.EnvInfisical, - )] = "work" - service, err := NewService(catalog.Builtin(), profiles) + service, err := NewService(catalog.Builtin()) if err != nil { t.Fatal(err) } @@ -258,22 +100,10 @@ func TestProjectSettingsReturnsEnvironmentAwareSafeProjection(t *testing.T) { project.Kind != workspacecore.ProjectKindApp { t.Fatalf("unexpected envelope: %#v", settings) } - if project.Environment.SelectedProfile != "work" || project.Environment.Profile == nil || - project.Environment.Profile.Source != "workspace-project-environment" { - t.Fatalf("environment profile = %#v", project.Environment) - } - if project.Container.SelectedProfile != "" || project.Deploy.SelectedProfile != "" { - t.Fatalf("inherited profiles reported as direct: %#v %#v", project.Container, project.Deploy) - } + if got := strings.Join(project.Environment.Keys, ","); got != "A_KEY,Z_KEY" { t.Fatalf("environment keys = %q", got) } - if project.Deploy.Config["projectId"] != "prj_demo" { - t.Fatalf("deploy config = %#v", project.Deploy.Config) - } - if _, leaked := project.Deploy.Config["apiToken"]; leaked { - t.Fatal("unknown token-like manifest field was reflected") - } raw, err := json.Marshal(settings) if err != nil { t.Fatal(err) @@ -282,163 +112,3 @@ func TestProjectSettingsReturnsEnvironmentAwareSafeProjection(t *testing.T) { t.Fatalf("settings leaked a credential: %s", raw) } } - -func TestProjectSettingsSurfacesStaleDirectBindingAndAllowsAutomaticFallback(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - before := snapshotWorkspaceTree(t, root) - profiles := projectProfileStub() - profiles.bindings[projectBindingKey( - root, "web", "staging", profile.DomainEnv, catalog.EnvInfisical, - )] = "deleted-profile" - service, err := NewService(catalog.Builtin(), profiles) - if err != nil { - t.Fatal(err) - } - - settings, err := service.ProjectSettings(context.Background(), root, "web", "preview") - if err != nil { - t.Fatal(err) - } - if settings.Project.Environment.SelectedProfile != "deleted-profile" { - t.Fatalf("stale direct binding was hidden: %#v", settings.Project.Environment) - } - if settings.Project.Environment.Profile != nil { - t.Fatalf("stale direct binding was reported as effective: %#v", settings.Project.Environment) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - - settings, err = service.UpdateProjectProfileBinding( - context.Background(), root, "web", "env", "preview", "", - ) - if err != nil { - t.Fatal(err) - } - if settings.Project.Environment.SelectedProfile != "" || - settings.Project.Environment.Profile == nil || - settings.Project.Environment.Profile.Name != "work" || - settings.Project.Environment.Profile.Source != "default" { - t.Fatalf("automatic fallback = %#v", settings.Project.Environment) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestProjectProfileBindingsOnlyChangeMachineLocalState(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - before := snapshotWorkspaceTree(t, root) - profiles := projectProfileStub() - service, err := NewService(catalog.Builtin(), profiles) - if err != nil { - t.Fatal(err) - } - for _, domain := range []string{"env", "container", "deploy"} { - settings, err := service.UpdateProjectProfileBinding( - context.Background(), root, "web", domain, "preview", "work", - ) - if err != nil { - t.Fatalf("bind %s: %v", domain, err) - } - if profiles.lastMode != "environment-bind" || settings.Environment != "preview" { - t.Fatalf("%s binding mode/settings = %q %#v", domain, profiles.lastMode, settings) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - } - - settings, err := service.UpdateProjectProfileBinding( - context.Background(), root, "web", "container", "preview", "", - ) - if err != nil { - t.Fatal(err) - } - if profiles.lastMode != "environment-unbind" || settings.Project.Container.SelectedProfile != "" { - t.Fatalf("unbind result = %q %#v", profiles.lastMode, settings.Project.Container) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - - settings, err = service.UpdateProjectProfileBinding( - context.Background(), root, "web", "container", "", "work", - ) - if err != nil { - t.Fatal(err) - } - if profiles.lastMode != "legacy-bind" || settings.Project.Container.SelectedProfile != "work" || - settings.Project.Container.Profile == nil || - settings.Project.Container.Profile.Source != "workspace-project" { - t.Fatalf("legacy binding result = %q %#v", profiles.lastMode, settings.Project.Container) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestLegacyUnbindDoesNotUpgradeManifestMissingWorkspaceID(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - manifest.Workspace.ID = "" - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - before := snapshotWorkspaceTree(t, root) - service, err := NewService(catalog.Builtin(), projectProfileStub()) - if err != nil { - t.Fatal(err) - } - if _, err := service.UpdateProjectProfileBinding( - context.Background(), root, "web", "env", "", "", - ); err != nil { - t.Fatalf("legacy unbind: %v", err) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestProjectProfileBindingRejectsInvalidInputWithoutChangingRepository(t *testing.T) { - for _, test := range []struct { - name string - mutate func(*workspacecore.Manifest) - project string - domain string - environment string - profileName string - }{ - {name: "unknown domain", project: "web", domain: "ci", environment: "preview", profileName: "work"}, - {name: "unknown project", project: "ghost", domain: "env", environment: "preview", profileName: "work"}, - {name: "unsafe environment", project: "web", domain: "env", environment: "../prod", profileName: "work"}, - {name: "padded environment", project: "web", domain: "env", environment: " preview ", profileName: "work"}, - {name: "unknown profile", project: "web", domain: "env", environment: "preview", profileName: "ghost"}, - { - name: "unknown manifest backend", project: "web", domain: "env", environment: "preview", profileName: "work", - mutate: func(manifest *workspacecore.Manifest) { - manifest.Domains.Env.Kind = "vault" - }, - }, - } { - t.Run(test.name, func(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - if test.mutate != nil { - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - test.mutate(manifest) - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - } - before := snapshotWorkspaceTree(t, root) - service, err := NewService(catalog.Builtin(), projectProfileStub()) - if err != nil { - t.Fatal(err) - } - _, err = service.UpdateProjectProfileBinding( - context.Background(), root, test.project, test.domain, test.environment, test.profileName, - ) - if err == nil { - t.Fatal("expected error") - } - if test.project != "ghost" && !errors.Is(err, ErrInvalidInput) { - t.Fatalf("error = %v; want ErrInvalidInput", err) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - }) - } -} diff --git a/packages/cli/internal/application/workspace/service.go b/packages/cli/internal/application/workspace/service.go index 2cfa9174..ef353bae 100644 --- a/packages/cli/internal/application/workspace/service.go +++ b/packages/cli/internal/application/workspace/service.go @@ -8,7 +8,6 @@ import ( "sync" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" ) @@ -18,36 +17,15 @@ var ( ) type Service struct { - catalog *catalog.Catalog - profiles ProfileAccess - mu sync.RWMutex + catalog *catalog.Catalog + mu sync.RWMutex } -// ProfileAccess is the narrow machine-profile capability needed by project -// settings. The configure application service implements this interface; the -// workspace package never needs profile values and only exposes the resolved -// profile name and its precedence source. -type ProfileAccess interface { - BindWorkspaceProfile(string, string, string, string, profile.Domain, string, string) error - UnbindWorkspaceProfile(string, string, profile.Domain, string) error - BindEnvironmentProfile(string, string, string, string, string, profile.Domain, string, string) error - UnbindEnvironmentProfile(string, string, string, profile.Domain, string) error - EnvironmentProfileBinding(string, string, string, profile.Domain, string) (string, error) - Resolve(profile.ResolveInput) (*profile.Resolved, error) -} - -// NewService constructs the workspace use-case boundary. profiles is optional -// so existing non-Dashboard callers and focused tests keep their lightweight -// construction path; production composition injects the configure service. -func NewService(backendCatalog *catalog.Catalog, profiles ...ProfileAccess) (*Service, error) { +func NewService(backendCatalog *catalog.Catalog) (*Service, error) { if backendCatalog == nil { return nil, errors.New("workspace: backend catalog is required") } - var profileAccess ProfileAccess - if len(profiles) > 0 { - profileAccess = profiles[0] - } - return &Service{catalog: backendCatalog, profiles: profileAccess}, nil + return &Service{catalog: backendCatalog}, nil } func (s *Service) Overview(root string, environments ...string) (workspacecore.Overview, error) { diff --git a/packages/cli/internal/application/workspace/service_test.go b/packages/cli/internal/application/workspace/service_test.go index cdc0b666..0d8a5bed 100644 --- a/packages/cli/internal/application/workspace/service_test.go +++ b/packages/cli/internal/application/workspace/service_test.go @@ -10,7 +10,7 @@ import ( func TestServiceOverviewIsAReadOnlyProjection(t *testing.T) { root := seedProjectSettingsWorkspace(t) before := snapshotWorkspaceTree(t, root) - service, err := NewService(catalog.Builtin(), projectProfileStub()) + service, err := NewService(catalog.Builtin()) if err != nil { t.Fatal(err) } diff --git a/packages/cli/internal/application/workspace/workspace_profile_settings.go b/packages/cli/internal/application/workspace/workspace_profile_settings.go deleted file mode 100644 index a39eea68..00000000 --- a/packages/cli/internal/application/workspace/workspace_profile_settings.go +++ /dev/null @@ -1,143 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// WorkspaceProfileSettingsSchema versions the safe workspace-level profile -// binding projection. It deliberately exposes only the selected profile name -// and resolution source; profile values and credentials remain private to the -// machine profile service. -const WorkspaceProfileSettingsSchema = "one-cli/workspace-profile/v1" - -type WorkspaceProfileSettings struct { - Schema string `json:"schema"` - Root string `json:"root"` - Environment string `json:"environment"` - Revision string `json:"revision"` - Domain string `json:"domain"` - Backend string `json:"backend,omitempty"` - Configurable bool `json:"configurable"` - SelectedProfile string `json:"selectedProfile"` - Profile *ProjectProfileRef `json:"profile,omitempty"` -} - -// WorkspaceEnvironmentProfile reads the environment backend from the shared -// manifest and resolves its effective machine-local Workspace profile. It is -// a projection only and never writes one.manifest.json. -func (s *Service) WorkspaceEnvironmentProfile( - _ context.Context, - root, environment string, -) (WorkspaceProfileSettings, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.workspaceEnvironmentProfile(root, environment) -} - -func (s *Service) workspaceEnvironmentProfile( - root, environment string, -) (WorkspaceProfileSettings, error) { - manifest, revision, err := workspacecore.ReadManifestSnapshot(root) - if err != nil { - return WorkspaceProfileSettings{}, err - } - environment, err = validateEnvironment(manifest, environment) - if err != nil { - return WorkspaceProfileSettings{}, err - } - backend := strings.TrimSpace(workspacecore.EnvBackend(manifest)) - profileEnvironment := workspacecore.ProfileBindingEnvironment(manifest, environment) - settings := WorkspaceProfileSettings{ - Schema: WorkspaceProfileSettingsSchema, - Root: root, - Environment: environment, - Revision: revision, - Domain: string(profile.DomainEnv), - Backend: backend, - } - if backend == "" { - return settings, nil - } - spec, ok := s.catalog.Lookup(catalog.DomainEnv, backend) - if !ok { - return WorkspaceProfileSettings{}, fmt.Errorf( - "%w: unknown env backend %q", ErrInvalidInput, backend, - ) - } - settings.Configurable = spec.Profile.Configurable - if !settings.Configurable { - return settings, nil - } - settings.Profile = s.resolveProfileRef( - manifest, root, profileEnvironment, "", profile.DomainEnv, backend, - ) - settings.SelectedProfile, err = s.directProfileSelection( - root, "", profileEnvironment, profile.DomainEnv, backend, settings.Profile, - ) - if err != nil { - return WorkspaceProfileSettings{}, err - } - return settings, nil -} - -// UpdateWorkspaceEnvironmentProfile changes only the machine-local Workspace -// binding. The backend remains owned by one.manifest.json and is therefore -// read-only here. An empty profile explicitly removes the Workspace binding -// and falls back to the normal resolver precedence. -func (s *Service) UpdateWorkspaceEnvironmentProfile( - _ context.Context, - root, environment, profileName string, -) (WorkspaceProfileSettings, error) { - s.mu.Lock() - defer s.mu.Unlock() - - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - return WorkspaceProfileSettings{}, err - } - backend := strings.TrimSpace(workspacecore.EnvBackend(manifest)) - if backend == "" { - return WorkspaceProfileSettings{}, fmt.Errorf( - "%w: env backend is not configured", ErrInvalidInput, - ) - } - spec, ok := s.catalog.Lookup(catalog.DomainEnv, backend) - if !ok { - return WorkspaceProfileSettings{}, fmt.Errorf( - "%w: unknown env backend %q", ErrInvalidInput, backend, - ) - } - if !spec.Profile.Configurable { - return WorkspaceProfileSettings{}, fmt.Errorf( - "%w: backend %s/%s does not accept a profile", - ErrInvalidInput, profile.DomainEnv, backend, - ) - } - if s.profiles == nil { - return WorkspaceProfileSettings{}, fmt.Errorf("workspace: profile service is unavailable") - } - environment, err = validateEnvironment(manifest, environment) - if err != nil { - return WorkspaceProfileSettings{}, err - } - bindingEnvironment := workspacecore.ProfileBindingEnvironment(manifest, environment) - if err := s.changeProfileBinding( - manifest, root, "", bindingEnvironment, profile.DomainEnv, backend, profileName, - ); err != nil { - return WorkspaceProfileSettings{}, err - } - return s.workspaceEnvironmentProfile(root, environment) -} - -func workspaceDirectSource(environment string) string { - if environment != "" { - return "workspace-environment" - } - return "workspace" -} diff --git a/packages/cli/internal/application/workspace/workspace_profile_settings_test.go b/packages/cli/internal/application/workspace/workspace_profile_settings_test.go deleted file mode 100644 index bbde0725..00000000 --- a/packages/cli/internal/application/workspace/workspace_profile_settings_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package workspace - -import ( - "context" - "errors" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func seedWorkspaceProfileSettings(t *testing.T, backend string) string { - t.Helper() - root := t.TempDir() - manifest := &workspacecore.Manifest{ - Version: workspacecore.ManifestVersion, - Workspace: &workspacecore.ManifestWorkspace{ID: "ws-profile", Name: "Profiles"}, - Projects: []workspacecore.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", TemplateID: "react-spa", Toolchain: "node", - }}, - } - if backend != "" { - manifest.Domains = &workspacecore.WorkspaceDomains{ - Env: &workspacecore.BackendRef{Kind: backend}, - } - } - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - return root -} - -func TestWorkspaceEnvironmentProfileBindsPerEnvironmentWithoutRepositoryWrites(t *testing.T) { - profiles := projectProfileStub() - service, err := NewService(catalog.Builtin(), profiles) - if err != nil { - t.Fatal(err) - } - root := seedWorkspaceProfileSettings(t, catalog.EnvInfisical) - before := snapshotWorkspaceTree(t, root) - - settings, err := service.UpdateWorkspaceEnvironmentProfile( - context.Background(), root, "preview", "work", - ) - if err != nil { - t.Fatal(err) - } - if settings.Schema != WorkspaceProfileSettingsSchema || settings.Environment != "preview" || - settings.Revision == "" || settings.SelectedProfile != "work" || settings.Profile == nil || - settings.Profile.Source != "workspace-environment" { - t.Fatalf("settings = %#v", settings) - } - if profiles.lastMode != "environment-bind" { - t.Fatalf("binding mode = %q", profiles.lastMode) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - - settings, err = service.UpdateWorkspaceEnvironmentProfile( - context.Background(), root, "preview", "", - ) - if err != nil { - t.Fatal(err) - } - if profiles.lastMode != "environment-unbind" || settings.SelectedProfile != "" { - t.Fatalf("unbind settings = %q %#v", profiles.lastMode, settings) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestWorkspaceEnvironmentProfileSurfacesStaleDirectBindingUntilUnbound(t *testing.T) { - profiles := projectProfileStub() - service, err := NewService(catalog.Builtin(), profiles) - if err != nil { - t.Fatal(err) - } - root := seedWorkspaceProfileSettings(t, catalog.EnvInfisical) - before := snapshotWorkspaceTree(t, root) - profiles.bindings[projectBindingKey( - root, "", "preview", profile.DomainEnv, catalog.EnvInfisical, - )] = "deleted-profile" - - settings, err := service.WorkspaceEnvironmentProfile( - context.Background(), root, "preview", - ) - if err != nil { - t.Fatal(err) - } - if settings.SelectedProfile != "deleted-profile" || settings.Profile != nil { - t.Fatalf("stale Workspace binding projection = %#v", settings) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - - settings, err = service.UpdateWorkspaceEnvironmentProfile( - context.Background(), root, "preview", "", - ) - if err != nil { - t.Fatal(err) - } - if settings.SelectedProfile != "" || settings.Profile == nil || - settings.Profile.Name != "work" || settings.Profile.Source != "default" { - t.Fatalf("automatic Workspace fallback = %#v", settings) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestWorkspaceEnvironmentProfileEmptyEnvironmentUsesLegacyBinding(t *testing.T) { - profiles := projectProfileStub() - service, err := NewService(catalog.Builtin(), profiles) - if err != nil { - t.Fatal(err) - } - root := seedWorkspaceProfileSettings(t, catalog.EnvInfisical) - before := snapshotWorkspaceTree(t, root) - - settings, err := service.UpdateWorkspaceEnvironmentProfile( - context.Background(), root, "", "work", - ) - if err != nil { - t.Fatal(err) - } - if profiles.lastMode != "legacy-bind" || settings.Environment != "" || - settings.SelectedProfile != "work" || settings.Profile == nil || - settings.Profile.Source != "workspace" { - t.Fatalf("legacy settings = %q %#v", profiles.lastMode, settings) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestWorkspaceEnvironmentProfileReadHandlesMissingAndNonConfigurableBackend(t *testing.T) { - service, err := NewService(catalog.Builtin(), projectProfileStub()) - if err != nil { - t.Fatal(err) - } - missingRoot := seedWorkspaceProfileSettings(t, "") - settings, err := service.WorkspaceEnvironmentProfile( - context.Background(), missingRoot, "preview", - ) - if err != nil { - t.Fatal(err) - } - if settings.Environment != "preview" || settings.Backend != "" || - settings.Configurable || settings.Profile != nil { - t.Fatalf("missing backend settings = %#v", settings) - } - - dotenvRoot := seedWorkspaceProfileSettings(t, catalog.EnvDotenv) - settings, err = service.WorkspaceEnvironmentProfile( - context.Background(), dotenvRoot, "staging_us2", - ) - if err != nil { - t.Fatal(err) - } - if settings.Environment != "staging_us2" || settings.Backend != catalog.EnvDotenv || - settings.Configurable || settings.Profile != nil { - t.Fatalf("dotenv settings = %#v", settings) - } - before := snapshotWorkspaceTree(t, dotenvRoot) - if _, err := service.UpdateWorkspaceEnvironmentProfile( - context.Background(), dotenvRoot, "preview", "", - ); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("dotenv update error = %v; want ErrInvalidInput", err) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, dotenvRoot), before) -} - -func TestWorkspaceEnvironmentProfileRejectsUnknownBackendProfileAndUnsafeEnvironment(t *testing.T) { - for _, test := range []struct { - name string - backend string - environment string - requested string - }{ - {name: "unknown backend", backend: "vault", environment: "preview", requested: "work"}, - {name: "unknown profile", backend: catalog.EnvInfisical, environment: "preview", requested: "ghost"}, - {name: "unsafe environment", backend: catalog.EnvInfisical, environment: "../preview", requested: "work"}, - } { - t.Run(test.name, func(t *testing.T) { - root := seedWorkspaceProfileSettings(t, test.backend) - before := snapshotWorkspaceTree(t, root) - service, err := NewService(catalog.Builtin(), projectProfileStub()) - if err != nil { - t.Fatal(err) - } - _, err = service.UpdateWorkspaceEnvironmentProfile( - context.Background(), root, test.environment, test.requested, - ) - if !errors.Is(err, ErrInvalidInput) { - t.Fatalf("error = %v; want ErrInvalidInput", err) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - }) - } -} diff --git a/packages/cli/internal/bootstrap/cli/dependencies.go b/packages/cli/internal/bootstrap/cli/dependencies.go index 7bf729e8..68124fd7 100644 --- a/packages/cli/internal/bootstrap/cli/dependencies.go +++ b/packages/cli/internal/bootstrap/cli/dependencies.go @@ -4,27 +4,17 @@ import ( "context" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/ci/githubactions" - deploybuild "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/build" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/cloudflare" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/edgeone" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/kustomize" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/s3compat" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/vercel" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/dotenv" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" miseruntime "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise" internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain" workspaceregistrylocal "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/workspaceregistry/local" ciapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/ci" - configureapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/configure" - deploymentapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/deployment" manifestapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/manifest" workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - containermodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/container" creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" - deployport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/deploy" runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" pkgci "github.com/torchstellar-team/one-cli/packages/cli/pkg/ci" @@ -36,8 +26,6 @@ import ( type dependencies struct { runtime runtimeport.Provider catalog *catalog.Catalog - profiles *configureapp.ProfileService - containers *containermodule.Service creation *creationmodule.Service environments *environmentmodule.Service manifest *manifestapp.Service @@ -51,24 +39,22 @@ func composeDependencies() dependencies { internaltoolchain.RegisterBundled() backendCatalog := catalog.Builtin() - profiles := mustProfileService(backendCatalog) - containers := mustContainerService(backendCatalog) - environments := mustEnvironmentService(backendCatalog, profiles) + + environments := mustEnvironmentService(backendCatalog) manifest := mustManifestService(backendCatalog) registry := mustWorkspaceRegistryService() creation := mustCreationService(environments, registry) return dependencies{ - runtime: miseruntime.Provider{}, - catalog: backendCatalog, - profiles: profiles, - containers: containers, + runtime: miseruntime.Provider{}, + catalog: backendCatalog, + creation: creation, environments: environments, manifest: manifest, loaders: secrets.MustRegistry(infisical.Loader(), dotenv.Loader()), ci: mustCIService(pkgci.MustRegistry(githubactions.Provider{})), - workspaces: mustWorkspaceService(backendCatalog, profiles), + workspaces: mustWorkspaceService(backendCatalog), registry: registry, } } @@ -95,9 +81,8 @@ func mustWorkspaceRegistryService() *workspaceapp.RegistryService { func mustWorkspaceService( backendCatalog *catalog.Catalog, - profiles *configureapp.ProfileService, ) *workspaceapp.Service { - service, err := workspaceapp.NewService(backendCatalog, profiles) + service, err := workspaceapp.NewService(backendCatalog) if err != nil { panic(err) } @@ -129,48 +114,8 @@ func mustCIService(providers *pkgci.Registry) *ciapp.Service { func mustEnvironmentService( backendCatalog *catalog.Catalog, - profiles *configureapp.ProfileService, ) *environmentmodule.Service { - service, err := environmentmodule.NewService(backendCatalog, profiles) - if err != nil { - panic(err) - } - return service -} - -func (d dependencies) newDeploymentService(buildVersion string) *deploymentapp.Service { - providers := []deployport.Provider{ - kustomize.NewProvider(buildVersion), - vercel.Provider(), - cloudflare.Provider(), - edgeone.Provider(), - } - providers = append(providers, s3compat.Providers()...) - service, err := deploymentapp.NewService( - d.catalog, - deployport.MustRegistry(providers...), - d.profiles, - d.loaders, - deploybuild.Local{}, - ) - if err != nil { - panic(err) - } - return service -} - -func mustProfileService(backendCatalog *catalog.Catalog) *configureapp.ProfileService { - service, err := configureapp.NewProfileService(backendCatalog, configureapp.LocalProfileRepository{}) - if err != nil { - panic(err) - } - return service -} - -func mustContainerService( - backendCatalog *catalog.Catalog, -) *containermodule.Service { - service, err := containermodule.NewService(backendCatalog) + service, err := environmentmodule.NewService(backendCatalog) if err != nil { panic(err) } diff --git a/packages/cli/internal/bootstrap/cli/root.go b/packages/cli/internal/bootstrap/cli/root.go index 83c8fd2a..4170b585 100644 --- a/packages/cli/internal/bootstrap/cli/root.go +++ b/packages/cli/internal/bootstrap/cli/root.go @@ -14,7 +14,6 @@ package cli import ( "context" "errors" - "fmt" "os" "strings" @@ -29,20 +28,21 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/preferences" platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/add" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/ci" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/configure" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/create" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/deploy" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/dev" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/env" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/hooks" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/mise" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/run" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/serve" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/skills" - "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/templates" + addcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/add" + authcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/auth" + buildcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/build" + cicmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/ci" + createcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/create" + devcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/dev" + envcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/env" + hookscmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/hooks" + initcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/init" + localecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/locale" + misecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/mise" + runcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/run" + servecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/serve" + skillscmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/skills" + templatescmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/templates" ) func newRootCommand() *cobra.Command { @@ -54,25 +54,18 @@ func newRootCommand() *cobra.Command { } groups := [][]*cobra.Command{ addcmd.Commands(deps.creation), + buildcmd.Commands(deps.runtime), cicmd.Commands(deps.ci), - configurecmd.Commands(deps.catalog, deps.profiles, deps.workspaces, deps.registry), - containercmd.Commands(containercmd.Dependencies{ - Service: deps.containers, - }), + authcmd.Commands(), + {localecmd.Command(), initcmd.Command()}, createcmd.Commands(createcmd.Dependencies{Creation: deps.creation}), - deploycmd.Commands(deploycmd.Dependencies{ - Catalog: deps.catalog, - Profiles: deps.profiles, - Creation: deps.creation, - NewService: deps.newDeploymentService, - }), devcmd.Commands(deps.runtime), misecmd.RuntimeCommands(deps.runtime), hookscmd.Commands(deps.runtime), envcmd.Commands(envcmd.Dependencies{Service: deps.environments}), runcmd.Commands(deps.loaders, deps.runtime), servecmd.Commands(servecmd.Dependencies{ - Catalog: deps.catalog, Profiles: deps.profiles, Workspaces: deps.workspaces, + Catalog: deps.catalog, Workspaces: deps.workspaces, Registry: deps.registry, Manifest: deps.manifest, Environments: deps.environments, }), templatescmd.Commands(), @@ -84,6 +77,7 @@ func newRootCommand() *cobra.Command { i18n.MarkShort(root, "root.short") root.SetVersionTemplate("{{.Version}}\n") root.SetHelpFunc(helpui.Render) + root.SetFlagErrorFunc(i18n.FlagError) root.PersistentFlags().StringP("output", "o", "", i18n.T("common.flag.output")) i18n.MarkFlagUsage(root, "output", "common.flag.output") return root @@ -199,7 +193,7 @@ func Execute(version string, args []string) (resultErr error) { if first, ok := firstPositional(args); ok && !isKnownSubcommand(first) { err := cliErrors.New( cliErrors.UNKNOWN_COMMAND, - fmt.Sprintf("未知命令: %s", first), + i18n.Tf("command.unknown", first), ).WithContext(map[string]any{"command": "one " + first}) output.EmitError(err) return err diff --git a/packages/cli/internal/bootstrap/cli/root_test.go b/packages/cli/internal/bootstrap/cli/root_test.go index d0b4183c..a28865d9 100644 --- a/packages/cli/internal/bootstrap/cli/root_test.go +++ b/packages/cli/internal/bootstrap/cli/root_test.go @@ -161,10 +161,10 @@ func TestIsKnownSubcommand(t *testing.T) { for _, name := range []string{ "create", "templates", "add", "skills", // Per-domain commands (post capability-interface refactor). - "env", "container", "dev", "deploy", "ci", + "env", "dev", "build", "ci", // configure owns the credential CRUD surface (renamed from // `profile` to align with industry standard CLIs). - "configure", + "login", "whoami", "logout", "locale", "init", } { if !isKnownSubcommand(name) { t.Errorf("isKnownSubcommand(%q) = false, want true", name) @@ -177,7 +177,7 @@ func TestIsKnownSubcommand(t *testing.T) { for _, name := range []string{ "doctor", "status", "unknown", "secrets", "skill", "prd", "design", "docker", "infisical", "dotenv", "procs", "compose", "k8s", - "plugins", "setup", "profile", + "plugins", "setup", "profile", "container", "deploy", "", } { if isKnownSubcommand(name) { diff --git a/packages/cli/internal/core/backend/builtin.go b/packages/cli/internal/core/backend/builtin.go index 9ac1a254..14891512 100644 --- a/packages/cli/internal/core/backend/builtin.go +++ b/packages/cli/internal/core/backend/builtin.go @@ -4,200 +4,28 @@ func builtinSpecs() []BackendSpec { return []BackendSpec{ envDotenvSpec(), envInfisicalSpec(), - s3Spec(DeployAliyunOSS, "https://oss-.aliyuncs.com", "cn-hangzhou", false), - s3Spec(DeployTencentCOS, "https://cos..myqcloud.com", "ap-guangzhou", false), - s3Spec(DeployAWSS3, "", "us-east-1", false), - s3Spec(DeployMinIO, "http://:9000", "us-east-1", true), - s3Spec(DeployRustFS, "http://:9000", "us-east-1", true), - s3Spec(DeployR2, "https://.r2.cloudflarestorage.com", "auto", false), - deployKustomizeSpec(), - deployVercelSpec(), - deployCloudflareSpec(), - deployEdgeOneSpec(), - containerSpec(ContainerDocker), - containerSpec(ContainerDockerHub), - containerSpec(ContainerGHCR), - containerSpec(ContainerACR), } } -func spec(id BackendID, capabilities []Capability, profile ProfileSpec, requirements ...Requirement) BackendSpec { +func spec(id BackendID, capabilities []Capability, requirements ...Requirement) BackendSpec { return BackendSpec{ ID: id, Pair: id.String(), Capabilities: capabilities, Requirements: requirements, - Profile: profile, } } -func field(path, inputName string, kind FieldType, label string, required bool) FieldSpec { - return FieldSpec{Path: path, InputName: inputName, Type: kind, LabelKey: label, Required: required} -} - -func projectField(path, inputName string, kind ProjectFieldType, label, placeholder string, required bool) ProjectFieldSpec { - return ProjectFieldSpec{ - Path: path, InputName: inputName, Type: kind, LabelKey: label, - Placeholder: placeholder, Required: required, - } -} - -func deployProjectSpec(fields ...ProjectFieldSpec) ProjectSpec { - return ProjectSpec{Configurable: true, Fields: fields} -} - -func deployEnvironmentField() ProjectFieldSpec { - return projectField("env", "environment", ProjectFieldEnvironment, "project.fields.environment", "", false) -} - func envDotenvSpec() BackendSpec { return spec( BackendID{Domain: DomainEnv, Name: EnvDotenv}, []Capability{CapabilityEnvGet, CapabilityEnvSet, CapabilityEnvList, CapabilityEnvInject, CapabilityScaffold}, - ProfileSpec{Type: ProfileTypeDotenv}, ) } func envInfisicalSpec() BackendSpec { - fields := []FieldSpec{ - field("siteUrl", "site-url", FieldString, "form.fields.siteUrl", false), - field("credentials/clientId", "client-id", FieldString, "form.fields.clientId", true), - field("credentials/clientSecret", "client-secret", FieldSecret, "form.fields.clientSecret", true), - } - fields[0].Default = "https://app.infisical.com" - fields[0].Placeholder = "https://infisical.company.com" return spec( BackendID{Domain: DomainEnv, Name: EnvInfisical}, []Capability{CapabilityEnvGet, CapabilityEnvSet, CapabilityEnvDelete, CapabilityEnvList, CapabilityEnvPull, CapabilityEnvInject, CapabilityScaffold}, - ProfileSpec{Configurable: true, Type: ProfileTypeInfisical, Fields: fields}, - Requirement{Kind: RequirementProfile, Name: "env/infisical"}, - ) -} - -func deployKustomizeSpec() BackendSpec { - fields := []FieldSpec{ - field("kubeconfigPath", "kubeconfig", FieldString, "form.fields.kubeconfigPath", false), - field("kubeconfigContext", "kubeconfig-context", FieldString, "form.fields.kubeconfigContext", false), - } - fields[0].Placeholder = "~/.kube/config" - result := spec( - BackendID{Domain: DomainDeploy, Name: DeployKustomize}, - []Capability{CapabilityDeploy, CapabilityScaffold}, - ProfileSpec{Configurable: true, Type: ProfileTypeKustomize, Fields: fields}, - Requirement{Kind: RequirementBinary, Name: "kubectl"}, - Requirement{Kind: RequirementCapability, Name: string(CapabilityContainerBuild)}, - Requirement{Kind: RequirementCapability, Name: string(CapabilityContainerPush)}, - ) - result.Project = deployProjectSpec(deployEnvironmentField()) - return result -} - -func s3Spec(name, endpoint, region string, pathStyle bool) BackendSpec { - fields := []FieldSpec{ - field("endpoint", "endpoint", FieldString, "form.fields.endpoint", false), - field("region", "region", FieldString, "form.fields.region", false), - field("forcePathStyle", "force-path-style", FieldBoolean, "form.fields.forcePathStyle", false), - field("credentials/accessKeyId", "access-key-id", FieldString, "form.fields.accessKeyId", true), - field("credentials/accessKeySecret", "access-key-secret", FieldSecret, "form.fields.accessKeySecret", true), - } - fields[0].Placeholder = endpoint - fields[1].Default = region - fields[1].Placeholder = region - fields[2].Default = pathStyle - result := spec( - BackendID{Domain: DomainDeploy, Name: name}, - []Capability{CapabilityDeploy}, - ProfileSpec{Configurable: true, Type: ProfileTypeS3, Fields: fields}, - Requirement{Kind: RequirementProfile, Name: "deploy/" + name}, - ) - result.Traits = []Trait{TraitS3Compatible} - result.Project = deployProjectSpec( - projectField("bucket", "bucket", ProjectFieldString, "project.fields.bucket", "my-static-site", false), - deployEnvironmentField(), - ) - return result -} - -func deployVercelSpec() BackendSpec { - result := spec( - BackendID{Domain: DomainDeploy, Name: DeployVercel}, - []Capability{CapabilityDeploy, CapabilityScaffold}, - ProfileSpec{Configurable: true, Type: ProfileTypeVercel, Fields: []FieldSpec{ - field("team", "team", FieldString, "form.fields.teamSlug", false), - field("credentials/apiToken", "token", FieldSecret, "form.fields.apiToken", true), - }}, - Requirement{Kind: RequirementProfile, Name: "deploy/vercel"}, - Requirement{Kind: RequirementBinary, Name: "vercel"}, - ) - result.Project = deployProjectSpec( - projectField("projectId", "project-id", ProjectFieldString, "project.fields.projectId", "prj_...", false), - projectField("projectName", "project-name", ProjectFieldString, "project.fields.projectName", "my-project", false), - deployEnvironmentField(), - ) - return result -} - -func deployCloudflareSpec() BackendSpec { - result := spec( - BackendID{Domain: DomainDeploy, Name: DeployCloudflare}, - []Capability{CapabilityDeploy, CapabilityScaffold}, - ProfileSpec{Configurable: true, Type: ProfileTypeCloudflare, Fields: []FieldSpec{ - field("accountId", "account-id", FieldString, "form.fields.accountId", false), - field("credentials/apiToken", "token", FieldSecret, "form.fields.apiToken", true), - }}, - Requirement{Kind: RequirementProfile, Name: "deploy/cloudflare"}, - Requirement{Kind: RequirementBinary, Name: "wrangler"}, - ) - result.Project = deployProjectSpec( - projectField("workerName", "worker-name", ProjectFieldString, "project.fields.workerName", "my-worker", false), - deployEnvironmentField(), - ) - return result -} - -func deployEdgeOneSpec() BackendSpec { - result := spec( - BackendID{Domain: DomainDeploy, Name: DeployEdgeOne}, - []Capability{CapabilityDeploy, CapabilityScaffold}, - ProfileSpec{Configurable: true, Type: ProfileTypeEdgeOne, Fields: []FieldSpec{ - field("region", "region", FieldString, "form.fields.regionEdgeOne", false), - field("credentials/apiToken", "token", FieldSecret, "form.fields.apiToken", false), - }}, - Requirement{Kind: RequirementProfile, Name: "deploy/edgeone"}, - Requirement{Kind: RequirementBinary, Name: "edgeone"}, - ) - result.Project = deployProjectSpec( - projectField("projectName", "project-name", ProjectFieldString, "project.fields.projectName", "my-project", false), - deployEnvironmentField(), - ) - return result -} - -func containerSpec(name string) BackendSpec { - fields := make([]FieldSpec, 0, 5) - if name == ContainerDocker { - entry := field("registry", "registry", FieldString, "form.fields.registry", true) - entry.Placeholder = "your-registry-host" - fields = append(fields, entry) - } - if name == ContainerACR { - entry := field("region", "region", FieldString, "form.fields.acrRegion", true) - entry.Default = "cn-hangzhou" - entry.Placeholder = "cn-hangzhou" - fields = append(fields, entry) - } - fields = append(fields, - field("credentials/username", "username", FieldString, "form.fields.username", true), - field("namespace", "namespace", FieldString, "form.fields.namespace", false), - field("credentials/password", "password", FieldSecret, "form.fields.password", true), - ) - result := spec( - BackendID{Domain: DomainContainer, Name: name}, - []Capability{CapabilityContainerInfo, CapabilityContainerBuild, CapabilityContainerPush, CapabilityScaffold}, - ProfileSpec{Configurable: true, Type: ProfileTypeContainer, Fields: fields}, - Requirement{Kind: RequirementProfile, Name: "container/" + name, Optional: true}, - Requirement{Kind: RequirementBinary, Name: "docker"}, ) - result.Traits = []Trait{TraitOCIRegistry} - return result } diff --git a/packages/cli/internal/core/backend/catalog.go b/packages/cli/internal/core/backend/catalog.go index e8915f2b..fdfc5f65 100644 --- a/packages/cli/internal/core/backend/catalog.go +++ b/packages/cli/internal/core/backend/catalog.go @@ -34,12 +34,6 @@ func New(specs ...BackendSpec) (*Catalog, error) { if len(spec.Capabilities) == 0 { return nil, fmt.Errorf("catalog: backend %q declares no capabilities", spec.Pair) } - if spec.Profile.Configurable && spec.Profile.Type == "" { - return nil, fmt.Errorf("catalog: configurable backend %q declares no profile type", spec.Pair) - } - if err := validateProfileFields(spec); err != nil { - return nil, err - } if err := validateProjectFields(spec); err != nil { return nil, err } @@ -49,45 +43,6 @@ func New(specs ...BackendSpec) (*Catalog, error) { return c, nil } -func validateProfileFields(spec BackendSpec) error { - paths := make(map[string]struct{}, len(spec.Profile.Fields)) - inputs := make(map[string]struct{}, len(spec.Profile.Fields)) - for _, field := range spec.Profile.Fields { - if strings.TrimSpace(field.Path) == "" || strings.TrimSpace(field.InputName) == "" || strings.TrimSpace(field.LabelKey) == "" { - return fmt.Errorf("catalog: backend %q has incomplete field metadata", spec.Pair) - } - if _, exists := paths[field.Path]; exists { - return fmt.Errorf("catalog: backend %q declares field path %q twice", spec.Pair, field.Path) - } - if _, exists := inputs[field.InputName]; exists { - return fmt.Errorf("catalog: backend %q declares input %q twice", spec.Pair, field.InputName) - } - paths[field.Path] = struct{}{} - inputs[field.InputName] = struct{}{} - switch field.Type { - case FieldString: - if field.Default != nil { - if _, ok := field.Default.(string); !ok { - return fmt.Errorf("catalog: backend %q field %q has a non-string default", spec.Pair, field.Path) - } - } - case FieldSecret: - if field.Default != nil { - return fmt.Errorf("catalog: backend %q secret field %q declares a default", spec.Pair, field.Path) - } - case FieldBoolean: - if field.Default != nil { - if _, ok := field.Default.(bool); !ok { - return fmt.Errorf("catalog: backend %q field %q has a non-boolean default", spec.Pair, field.Path) - } - } - default: - return fmt.Errorf("catalog: backend %q field %q has unknown type %q", spec.Pair, field.Path, field.Type) - } - } - return nil -} - func validateProjectFields(spec BackendSpec) error { if !spec.Project.Configurable { if len(spec.Project.Fields) > 0 { @@ -95,9 +50,6 @@ func validateProjectFields(spec BackendSpec) error { } return nil } - if !spec.Has(CapabilityDeploy) { - return fmt.Errorf("catalog: project-configurable backend %q does not declare deploy capability", spec.Pair) - } if len(spec.Project.Fields) == 0 { return fmt.Errorf("catalog: project-configurable backend %q declares no project fields", spec.Pair) } @@ -154,7 +106,6 @@ func cloneSpec(spec BackendSpec) BackendSpec { spec.Capabilities = append([]Capability(nil), spec.Capabilities...) spec.Traits = append([]Trait(nil), spec.Traits...) spec.Requirements = append([]Requirement(nil), spec.Requirements...) - spec.Profile.Fields = append([]FieldSpec(nil), spec.Profile.Fields...) spec.Project.Fields = append([]ProjectFieldSpec(nil), spec.Project.Fields...) return spec } @@ -199,20 +150,6 @@ func (c *Catalog) ForDomain(domain Domain) []BackendSpec { return out } -// ProfileBackends returns only backends that expose a configure profile. -func (c *Catalog) ProfileBackends() []BackendSpec { - if c == nil { - return nil - } - var out []BackendSpec - for _, spec := range c.ordered { - if spec.Profile.Configurable { - out = append(out, cloneSpec(spec)) - } - } - return out -} - // Lookup validates a domain and bare backend name. func (c *Catalog) Lookup(domain Domain, name string) (BackendSpec, bool) { return c.LookupPair(BackendID{Domain: domain, Name: name}.String()) diff --git a/packages/cli/internal/core/backend/catalog_test.go b/packages/cli/internal/core/backend/catalog_test.go index ddb88a5e..645cd6a6 100644 --- a/packages/cli/internal/core/backend/catalog_test.go +++ b/packages/cli/internal/core/backend/catalog_test.go @@ -11,20 +11,6 @@ func TestBuiltinPairs(t *testing.T) { got := Builtin().SortedPairs() want := []string{ - "container/acr", - "container/docker", - "container/dockerhub", - "container/ghcr", - "deploy/aliyun-oss", - "deploy/aws-s3", - "deploy/cloudflare", - "deploy/edgeone", - "deploy/kustomize", - "deploy/minio", - "deploy/r2", - "deploy/rustfs", - "deploy/tencent-cos", - "deploy/vercel", "env/dotenv", "env/infisical", } @@ -33,27 +19,12 @@ func TestBuiltinPairs(t *testing.T) { } } -func TestBuiltinProfileBackendsExcludeDotenv(t *testing.T) { - t.Parallel() - - got := Builtin().ProfileBackends() - if len(got) != 15 { - t.Fatalf("len(ProfileBackends()) = %d, want 15", len(got)) - } - for _, spec := range got { - if spec.Pair == "env/dotenv" { - t.Fatal("env/dotenv must not expose a configure profile") - } - } -} - func TestNewRejectsDuplicateAndMalformedSpecs(t *testing.T) { t.Parallel() valid := spec( BackendID{Domain: DomainEnv, Name: "test"}, []Capability{CapabilityEnvGet}, - ProfileSpec{}, ) if _, err := New(valid, valid); err == nil { t.Fatal("New() accepted duplicate backend") @@ -66,52 +37,14 @@ func TestNewRejectsDuplicateAndMalformedSpecs(t *testing.T) { } } -func TestNewRejectsConfigurableBackendWithoutProfileType(t *testing.T) { - t.Parallel() - - _, err := New(spec( - BackendID{Domain: DomainEnv, Name: "test"}, - []Capability{CapabilityEnvGet}, - ProfileSpec{Configurable: true}, - )) - if err == nil { - t.Fatal("New() accepted configurable backend without profile type") - } -} - -func TestNewRejectsInvalidProfileFieldMetadata(t *testing.T) { - t.Parallel() - - base := spec( - BackendID{Domain: DomainEnv, Name: "test"}, - []Capability{CapabilityEnvGet}, - ProfileSpec{Configurable: true, Type: ProfileTypeInfisical}, - ) - base.Profile.Fields = []FieldSpec{ - {Path: "siteUrl", InputName: "site-url", Type: FieldString, LabelKey: "site"}, - {Path: "credentials/clientId", InputName: "site-url", Type: FieldString, LabelKey: "client"}, - } - if _, err := New(base); err == nil { - t.Fatal("New() accepted duplicate profile input names") - } - - base.Profile.Fields = []FieldSpec{{ - Path: "credentials/clientSecret", InputName: "client-secret", Type: FieldSecret, - LabelKey: "secret", Default: "must-not-be-stored", - }} - if _, err := New(base); err == nil { - t.Fatal("New() accepted a default secret") - } -} - func TestCatalogReturnsDefensiveCopies(t *testing.T) { t.Parallel() c := Builtin() specs := c.All() specs[0].Capabilities[0] = "mutated" - deploySpecs := c.ForDomain(DomainDeploy) - deploySpecs[0].Project.Fields[0].Path = "mutated" + envSpecs := c.ForDomain(DomainEnv) + envSpecs[1].Capabilities[0] = "mutated" got, ok := c.LookupPair("env/dotenv") if !ok { @@ -120,12 +53,12 @@ func TestCatalogReturnsDefensiveCopies(t *testing.T) { if got.Capabilities[0] == "mutated" { t.Fatal("All() leaked mutable catalog storage") } - deploy, ok := c.LookupPair("deploy/aliyun-oss") + env, ok := c.LookupPair("env/infisical") if !ok { - t.Fatal("deploy/aliyun-oss not found") + t.Fatal("env/infisical not found") } - if deploy.Project.Fields[0].Path == "mutated" { - t.Fatal("ForDomain() leaked mutable project field storage") + if env.Capabilities[0] == "mutated" { + t.Fatal("ForDomain() leaked mutable profile field storage") } } @@ -133,9 +66,8 @@ func TestNewRejectsInvalidProjectFieldMetadata(t *testing.T) { t.Parallel() valid := spec( - BackendID{Domain: DomainDeploy, Name: "test"}, - []Capability{CapabilityDeploy}, - ProfileSpec{}, + BackendID{Domain: DomainEnv, Name: "test"}, + []Capability{CapabilityEnvGet}, ) valid.Project = ProjectSpec{Configurable: true, Fields: []ProjectFieldSpec{{ Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, @@ -152,12 +84,6 @@ func TestNewRejectsInvalidProjectFieldMetadata(t *testing.T) { value.Project.Configurable = false }, }, - { - name: "configurable requires deploy capability", - mutate: func(value *BackendSpec) { - value.Capabilities = []Capability{CapabilityScaffold} - }, - }, { name: "configurable requires fields", mutate: func(value *BackendSpec) { @@ -214,131 +140,27 @@ func TestNewRejectsInvalidProjectFieldMetadata(t *testing.T) { } } -func TestBuiltinDeployProjectFields(t *testing.T) { - t.Parallel() - - wantS3 := []ProjectFieldSpec{ - {Path: "bucket", InputName: "bucket", Type: ProjectFieldString, LabelKey: "project.fields.bucket", Placeholder: "my-static-site"}, - {Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, LabelKey: "project.fields.environment"}, - } - for _, name := range []string{ - DeployAliyunOSS, DeployTencentCOS, DeployAWSS3, - DeployMinIO, DeployRustFS, DeployR2, - } { - got, ok := Builtin().Lookup(DomainDeploy, name) - if !ok { - t.Fatalf("deploy/%s not found", name) - } - if !got.Project.Configurable || !reflect.DeepEqual(got.Project.Fields, wantS3) { - t.Fatalf("deploy/%s project schema = %#v, want %#v", name, got.Project, wantS3) - } - } - - tests := []struct { - name string - want []ProjectFieldSpec - }{ - { - name: DeployKustomize, - want: []ProjectFieldSpec{{Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, LabelKey: "project.fields.environment"}}, - }, - { - name: DeployVercel, - want: []ProjectFieldSpec{ - {Path: "projectId", InputName: "project-id", Type: ProjectFieldString, LabelKey: "project.fields.projectId", Placeholder: "prj_..."}, - {Path: "projectName", InputName: "project-name", Type: ProjectFieldString, LabelKey: "project.fields.projectName", Placeholder: "my-project"}, - {Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, LabelKey: "project.fields.environment"}, - }, - }, - { - name: DeployCloudflare, - want: []ProjectFieldSpec{ - {Path: "workerName", InputName: "worker-name", Type: ProjectFieldString, LabelKey: "project.fields.workerName", Placeholder: "my-worker"}, - {Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, LabelKey: "project.fields.environment"}, - }, - }, - { - name: DeployEdgeOne, - want: []ProjectFieldSpec{ - {Path: "projectName", InputName: "project-name", Type: ProjectFieldString, LabelKey: "project.fields.projectName", Placeholder: "my-project"}, - {Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, LabelKey: "project.fields.environment"}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, ok := Builtin().Lookup(DomainDeploy, tt.name) - if !ok { - t.Fatalf("deploy/%s not found", tt.name) - } - if !got.Project.Configurable || !reflect.DeepEqual(got.Project.Fields, tt.want) { - t.Fatalf("deploy/%s project schema = %#v, want %#v", tt.name, got.Project, tt.want) - } - }) - } - - for _, domain := range []Domain{DomainEnv, DomainContainer} { - for _, got := range Builtin().ForDomain(domain) { - if got.Project.Configurable || len(got.Project.Fields) != 0 { - t.Fatalf("%s must not declare backend-specific project fields: %#v", got.Pair, got.Project) - } - } - } -} - -func TestProfileFieldsNeverExposeCredentialValues(t *testing.T) { - t.Parallel() - - for _, backend := range Builtin().ProfileBackends() { - for _, field := range backend.Profile.Fields { - if field.Path == "" || field.InputName == "" || field.LabelKey == "" { - t.Fatalf("%s has incomplete field metadata: %#v", backend.Pair, field) - } - if field.Type == FieldSecret && field.Default != nil { - t.Fatalf("%s secret %s must not declare a default", backend.Pair, field.Path) - } - } - } -} - -func TestBuiltinBackendsDeclareProfileType(t *testing.T) { - t.Parallel() - - for _, backend := range Builtin().All() { - if backend.Profile.Type == "" { - t.Fatalf("%s has no profile type", backend.Pair) - } - } -} - func TestBackendSpecJSONIncludesNormalizedIdentity(t *testing.T) { t.Parallel() - backend, ok := Builtin().LookupPair("deploy/vercel") + backend, ok := Builtin().LookupPair("env/infisical") if !ok { - t.Fatal("deploy/vercel not found") + t.Fatal("env/infisical not found") } raw, err := json.Marshal(backend) if err != nil { t.Fatal(err) } var got struct { - ID string `json:"id"` - Domain Domain `json:"domain"` - Name string `json:"name"` - Project ProjectSpec `json:"project"` + ID string `json:"id"` + Domain Domain `json:"domain"` + Name string `json:"name"` } if err := json.Unmarshal(raw, &got); err != nil { t.Fatal(err) } - if got.ID != "deploy/vercel" || got.Domain != DomainDeploy || got.Name != "vercel" { + if got.ID != "env/infisical" || got.Domain != DomainEnv || got.Name != "infisical" { t.Fatalf("identity = %#v", got) } - if !got.Project.Configurable || len(got.Project.Fields) != 3 { - t.Fatalf("project schema = %#v", got.Project) - } - if got.Project.Fields[0].Path != "projectId" || got.Project.Fields[2].Type != ProjectFieldEnvironment { - t.Fatalf("project fields = %#v", got.Project.Fields) - } + } diff --git a/packages/cli/internal/core/backend/types.go b/packages/cli/internal/core/backend/types.go index f3a1cb82..a6f2593b 100644 --- a/packages/cli/internal/core/backend/types.go +++ b/packages/cli/internal/core/backend/types.go @@ -14,9 +14,7 @@ import ( type Domain string const ( - DomainEnv Domain = "env" - DomainDeploy Domain = "deploy" - DomainContainer Domain = "container" + DomainEnv Domain = "env" ) // Built-in backend names are declared beside the Catalog so other packages do @@ -25,31 +23,15 @@ const ( const ( EnvDotenv = "dotenv" EnvInfisical = "infisical" - - DeployAliyunOSS = "aliyun-oss" - DeployTencentCOS = "tencent-cos" - DeployAWSS3 = "aws-s3" - DeployMinIO = "minio" - DeployRustFS = "rustfs" - DeployR2 = "r2" - DeployKustomize = "kustomize" - DeployVercel = "vercel" - DeployCloudflare = "cloudflare" - DeployEdgeOne = "edgeone" - - ContainerDocker = "docker" - ContainerDockerHub = "dockerhub" - ContainerGHCR = "ghcr" - ContainerACR = "acr" ) // Domains is the stable product display order. func Domains() []Domain { - return []Domain{DomainEnv, DomainDeploy, DomainContainer} + return []Domain{DomainEnv} } // BackendID is the canonical identity of one backend. String renders the -// compatibility pair used by profile storage and configure routes. +// transport identity used by the backend catalog. type BackendID struct { Domain Domain `json:"domain"` Name string `json:"name"` @@ -78,17 +60,13 @@ func ParseBackendID(pair string) (BackendID, bool) { type Capability string const ( - CapabilityEnvGet Capability = "env/get" - CapabilityEnvSet Capability = "env/set" - CapabilityEnvDelete Capability = "env/delete" - CapabilityEnvList Capability = "env/list" - CapabilityEnvPull Capability = "env/pull" - CapabilityEnvInject Capability = "env/inject" - CapabilityScaffold Capability = "scaffold" - CapabilityContainerInfo Capability = "container/info" - CapabilityContainerBuild Capability = "container/build" - CapabilityContainerPush Capability = "container/push" - CapabilityDeploy Capability = "deploy" + CapabilityEnvGet Capability = "env/get" + CapabilityEnvSet Capability = "env/set" + CapabilityEnvDelete Capability = "env/delete" + CapabilityEnvList Capability = "env/list" + CapabilityEnvPull Capability = "env/pull" + CapabilityEnvInject Capability = "env/inject" + CapabilityScaffold Capability = "scaffold" ) // Trait describes a shared wire/protocol family that is orthogonal to a @@ -96,10 +74,7 @@ const ( // without maintaining a second backend identity list. type Trait string -const ( - TraitS3Compatible Trait = "s3-compatible" - TraitOCIRegistry Trait = "oci-registry" -) +const () // RequirementKind describes a dependency that must be satisfied before a // backend operation begins. @@ -108,7 +83,6 @@ type RequirementKind string const ( RequirementBinary RequirementKind = "binary" RequirementCapability RequirementKind = "capability" - RequirementProfile RequirementKind = "profile" ) // Requirement is a declarative coeffect. The first implementation validates @@ -119,58 +93,7 @@ type Requirement struct { Optional bool `json:"optional,omitempty"` } -// FieldType is the transport-neutral form control for a profile field. -type FieldType string - -const ( - FieldString FieldType = "string" - FieldSecret FieldType = "secret" - FieldBoolean FieldType = "boolean" -) - -// FieldSpec describes a leaf in the existing typed profile JSON shape. Path -// uses slash-separated JSON keys so credentials remain nested on the wire; -// InputName is the stable transport input name used by CLI flags and other -// clients that need a non-localized field identifier. -type FieldSpec struct { - Path string `json:"path"` - InputName string `json:"input_name"` - Type FieldType `json:"type"` - LabelKey string `json:"label_key"` - Required bool `json:"required,omitempty"` - Placeholder string `json:"placeholder,omitempty"` - Default any `json:"default,omitempty"` -} - -// ProfileType identifies the typed profile shape used by a backend. It is an -// internal schema discriminator, not a user-facing backend identity. Multiple -// backends can share one type (for example every S3-compatible backend), which -// lets profile workflows dispatch once per shape instead of once per backend. -type ProfileType string - -const ( - ProfileTypeDotenv ProfileType = "dotenv" - ProfileTypeInfisical ProfileType = "infisical" - ProfileTypeS3 ProfileType = "s3" - ProfileTypeKustomize ProfileType = "kustomize" - ProfileTypeVercel ProfileType = "vercel" - ProfileTypeCloudflare ProfileType = "cloudflare" - ProfileTypeEdgeOne ProfileType = "edgeone" - ProfileTypeContainer ProfileType = "container" -) - -// ProfileSpec describes whether and how a machine profile is configured for -// a backend. It contains schema metadata only, never profile values. -type ProfileSpec struct { - Configurable bool `json:"configurable"` - Type ProfileType `json:"-"` - Fields []FieldSpec `json:"fields,omitempty"` -} - -// ProjectFieldType is the transport-neutral control used to edit one -// backend-owned value in projects[i].domains..config. It is separate -// from FieldType because project settings are safe workspace metadata, while -// profile fields may contain machine-local credentials. +// ProjectFieldType describes safe workspace metadata. type ProjectFieldType string const ( @@ -193,8 +116,7 @@ type ProjectFieldSpec struct { // ProjectSpec describes the backend-owned fields that may be persisted in a // project's manifest config. It contains schema metadata only, never values or -// credentials. Container's common kind/image/namespace settings deliberately -// stay outside this backend-specific schema. +// credentials. type ProjectSpec struct { Configurable bool `json:"configurable"` Fields []ProjectFieldSpec `json:"fields,omitempty"` @@ -208,13 +130,12 @@ type BackendSpec struct { Capabilities []Capability `json:"capabilities"` Traits []Trait `json:"traits,omitempty"` Requirements []Requirement `json:"requirements,omitempty"` - Profile ProfileSpec `json:"profile"` Project ProjectSpec `json:"project"` } // MarshalJSON exposes the normalized ID components without storing a second, // potentially inconsistent copy on BackendSpec. Pair remains the compatibility -// identity used by profile storage; domain and name make the catalog directly +// identity used by the backend catalog; domain and name make the catalog directly // consumable by transports such as the Dashboard. func (s BackendSpec) MarshalJSON() ([]byte, error) { type wireBackendSpec struct { @@ -224,7 +145,6 @@ func (s BackendSpec) MarshalJSON() ([]byte, error) { Capabilities []Capability `json:"capabilities"` Traits []Trait `json:"traits,omitempty"` Requirements []Requirement `json:"requirements,omitempty"` - Profile ProfileSpec `json:"profile"` Project ProjectSpec `json:"project"` } return json.Marshal(wireBackendSpec{ @@ -234,7 +154,6 @@ func (s BackendSpec) MarshalJSON() ([]byte, error) { Capabilities: s.Capabilities, Traits: s.Traits, Requirements: s.Requirements, - Profile: s.Profile, Project: s.Project, }) } diff --git a/packages/cli/internal/core/container/types.go b/packages/cli/internal/core/container/types.go deleted file mode 100644 index 0daf9219..00000000 --- a/packages/cli/internal/core/container/types.go +++ /dev/null @@ -1,150 +0,0 @@ -// Package container owns the transport-neutral inputs and results for OCI -// image inspection, build, push, and registry resolution. Execution lives in -// the compiled container module and Docker adapter; these types are not an -// extension interface. -package container - -import ( - "fmt" - "io" - "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -// Registry is the resolved OCI registry endpoint and credentials used to -// compose image tags. An empty Registry means a local-daemon-only build. -type Registry struct { - Registry string - Namespace string - Username string - Password string - ProfileName string - ProfileSource string -} - -// HasCredentials reports whether the registry is fully populated for login. -func (r *Registry) HasCredentials() bool { - return r != nil && r.Registry != "" && r.Username != "" && r.Password != "" -} - -// ImageTagVersion returns the tag suffix of an OCI image reference. A colon -// in the registry host is ignored, so localhost:5000/team/api:v1 returns v1. -func ImageTagVersion(reference string) string { - reference = strings.TrimSpace(reference) - if reference == "" { - return "" - } - colon := strings.LastIndex(reference, ":") - slash := strings.LastIndex(reference, "/") - if colon > slash { - return reference[colon+1:] - } - return "" -} - -type InfoInput struct { - ProjectRoot string - TargetNames []string -} - -type ProjectInfo struct { - Name string `json:"name"` - RelativeDir string `json:"relative_dir"` - Backend string `json:"backend,omitempty"` - HasArtifact bool `json:"has_artifact"` - ArtifactPath string `json:"artifact_path,omitempty"` - WorkloadName string `json:"workload_name,omitempty"` - ImageOverride string `json:"image_override,omitempty"` -} - -type InfoResult struct { - Schema string `json:"schema"` - Workspace string `json:"workspace"` - ContainerBackend string `json:"container_backend"` - Projects []ProjectInfo `json:"projects"` -} - -func (r *InfoResult) RenderTTY(w io.Writer) { - if r == nil { - return - } - fmt.Fprintf(w, i18n.T("container.info_title")+"\n", r.Workspace) - for _, project := range r.Projects { - state := i18n.T("container.artifact_missing") - if project.HasArtifact { - state = i18n.T("container.artifact_ready") - } - fmt.Fprintf(w, " %s %s\n", project.Name, state) - } -} - -type BuildInput struct { - ProjectRoot string - Project string - TargetNames []string - Tag string - Platform string - DryRun bool - Registry *Registry -} - -type BuildEntry struct { - Project string `json:"project"` - Image string `json:"image"` - Argv []string `json:"argv"` - DryRun bool `json:"dry_run"` -} - -type BuildResult struct { - Schema string `json:"schema"` - Built []BuildEntry `json:"built"` -} - -func (r *BuildResult) RenderTTY(w io.Writer) { - if r == nil { - return - } - for _, entry := range r.Built { - fmt.Fprintf(w, i18n.T("container.build_success")+"\n", entry.Project, entry.Image) - } - if len(r.Built) == 1 { - fmt.Fprintln(w) - fmt.Fprintf(w, i18n.T("container.build_next_project")+"\n", r.Built[0].Project) - } else if len(r.Built) > 1 { - fmt.Fprintln(w) - fmt.Fprintln(w, i18n.T("container.build_next_all")) - } -} - -type PushInput struct { - ProjectRoot string - Project string - TargetNames []string - Tag string - DryRun bool - Registry *Registry -} - -type PushEntry struct { - Project string `json:"project"` - Image string `json:"image"` - SourceImage string `json:"source_image,omitempty"` - Retagged bool `json:"retagged,omitempty"` - Argv []string `json:"argv"` - DryRun bool `json:"dry_run"` -} - -type PushResult struct { - Schema string `json:"schema"` - Pushed []PushEntry `json:"pushed"` -} - -func (r *PushResult) RenderTTY(w io.Writer) { - if r == nil { - return - } - for _, entry := range r.Pushed { - fmt.Fprintf(w, i18n.T("container.push_success")+"\n", entry.Project, entry.Image) - } -} diff --git a/packages/cli/internal/core/container/types_test.go b/packages/cli/internal/core/container/types_test.go deleted file mode 100644 index 43533d2b..00000000 --- a/packages/cli/internal/core/container/types_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package container - -import "testing" - -func TestRegistryHasCredentials(t *testing.T) { - tests := []struct { - name string - r *Registry - want bool - }{ - {"nil", nil, false}, - {"empty", &Registry{}, false}, - {"only host", &Registry{Registry: "ghcr.io"}, false}, - {"only user", &Registry{Registry: "ghcr.io", Username: "u"}, false}, - {"full", &Registry{Registry: "ghcr.io", Username: "u", Password: "p"}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.r.HasCredentials(); got != tt.want { - t.Fatalf("HasCredentials() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestImageTagVersion(t *testing.T) { - tests := map[string]string{ - "api:v1.2.3": "v1.2.3", - "ghcr.io/team/api:latest": "latest", - "localhost:5000/team/api:v2.0.0": "v2.0.0", - "ghcr.io/team/api": "", - } - for reference, want := range tests { - if got := ImageTagVersion(reference); got != want { - t.Errorf("ImageTagVersion(%q) = %q, want %q", reference, got, want) - } - } -} diff --git a/packages/cli/internal/core/profile/bindings.go b/packages/cli/internal/core/profile/bindings.go deleted file mode 100644 index eb267785..00000000 --- a/packages/cli/internal/core/profile/bindings.go +++ /dev/null @@ -1,660 +0,0 @@ -package profile - -// bindings.go owns the Dashboard's environment-aware profile selections. -// -// Profile definitions and credentials continue to live in config.json and -// credentials.json. This third, machine-local file only records which named -// profile a workspace/environment (and, optionally, one of its projects) -// selects: -// -// ~/.config/one/profile-bindings.json -// -// The canonical workspace root is the identity key. That deliberately keeps -// two checkouts/copies of a workspace independent even when their shared -// manifests carry the same workspace id. Nothing in this store is written to -// the workspace itself. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "time" - "unicode" - - "github.com/gofrs/flock" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" -) - -const ( - bindingsSchemaVersion = 1 - bindingsLockRetryDelay = 10 * time.Millisecond - // Keep the default bounded, but leave enough room for queued cross-process - // writes on slower Windows filesystems. An earlier caller deadline still wins. - bindingsLockTimeout = 10 * time.Second -) - -var ( - bindingsMu sync.RWMutex - bindingIdentifierRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) -) - -// bindingsFile is intentionally separate from Config. Config is the v1 -// profile-definition schema and remains byte-for-byte compatible with older -// clients; environment-aware Dashboard selections never cause config.json or -// credentials.json to be rewritten. -type bindingsFile struct { - Version int `json:"version"` - Workspaces map[string]bindingsWorkspace `json:"workspaces,omitempty"` -} - -type bindingsWorkspace struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Environments map[string]bindingsEnvironment `json:"environments,omitempty"` -} - -type bindingsEnvironment struct { - Profiles map[string]string `json:"profiles,omitempty"` - Projects map[string]bindingsProjectProfile `json:"projects,omitempty"` -} - -type bindingsProjectProfile struct { - Profiles map[string]string `json:"profiles,omitempty"` -} - -// BindingsPath returns the machine-local environment profile-selection file. -func BindingsPath() (string, error) { - root, err := configRoot() - if err != nil { - return "", err - } - return filepath.Join(root, "profile-bindings.json"), nil -} - -// EnvironmentProfileBinding returns the raw binding at exactly one scope. -// Unlike Resolve, this read does not require the referenced Profile definition -// to exist. Dashboard projections use that distinction to surface and remove -// stale machine-local selections instead of silently hiding them. -// -// An empty projectName reads the Workspace binding; a non-empty projectName -// reads only that Project's direct binding and does not fall back to Workspace. -func EnvironmentProfileBinding( - root, projectName, environment string, - domain Domain, - backend string, -) (string, error) { - if err := validateBackend(domain, backend); err != nil { - return "", err - } - validatedProjectName, err := validateBindingMetadata("project name", projectName, true) - if err != nil { - return "", err - } - projectBinding, workspaceBinding, err := environmentBindingNamesAt( - root, environment, validatedProjectName, SectionKey(domain, backend), - ) - if err != nil { - return "", err - } - if validatedProjectName != "" { - return projectBinding, nil - } - return workspaceBinding, nil -} - -// BindEnvironmentProfile records one environment-aware workspace or project -// selection. The selected profile must already exist in the matching typed -// profile section. Validation reads config.json/credentials.json but this -// operation writes only profile-bindings.json. -func BindEnvironmentProfile( - workspaceID, workspaceName, root, projectName, environment string, - domain Domain, - backend, name string, -) error { - canonicalRoot, err := canonicalBindingRoot(root) - if err != nil { - return err - } - environment, err = validateBindingEnvironment(environment) - if err != nil { - return err - } - name, err = validateBindingProfileName(name) - if err != nil { - return err - } - if err := validateBackend(domain, backend); err != nil { - return err - } - - workspaceID, err = validateBindingMetadata("workspace id", workspaceID, false) - if err != nil { - return err - } - workspaceName, err = validateBindingMetadata("workspace name", workspaceName, false) - if err != nil { - return err - } - projectName, err = validateBindingMetadata("project name", projectName, true) - if err != nil { - return err - } - - bindingsMu.Lock() - defer bindingsMu.Unlock() - - path, err := BindingsPath() - if err != nil { - return err - } - return updateBindingsAt(context.Background(), path, func(bindings *bindingsFile) (bool, error) { - // Re-check existence while holding the binding store's exclusive lock. - // Profile removal holds the matching shared lock through its config Save, - // so a writer queued behind removal cannot publish a newly stale binding. - cfg, _, err := Load() - if err != nil { - return false, err - } - if exists, names := profileExists(cfg, domain, backend, name); !exists { - source := "workspace-environment" - if projectName != "" { - source = "workspace-project-environment" - } - return false, profileNotFound(SectionKey(domain, backend), name, source, names) - } - if bindings.Workspaces == nil { - bindings.Workspaces = make(map[string]bindingsWorkspace) - } - workspace := bindings.Workspaces[canonicalRoot] - if workspaceID != "" { - workspace.ID = workspaceID - } - if workspaceName != "" { - workspace.Name = workspaceName - } - if workspace.Environments == nil { - workspace.Environments = make(map[string]bindingsEnvironment) - } - selection := workspace.Environments[environment] - sectionKey := SectionKey(domain, backend) - if projectName == "" { - if selection.Profiles == nil { - selection.Profiles = make(map[string]string) - } - selection.Profiles[sectionKey] = name - } else { - if selection.Projects == nil { - selection.Projects = make(map[string]bindingsProjectProfile) - } - project := selection.Projects[projectName] - if project.Profiles == nil { - project.Profiles = make(map[string]string) - } - project.Profiles[sectionKey] = name - selection.Projects[projectName] = project - } - workspace.Environments[environment] = selection - bindings.Workspaces[canonicalRoot] = workspace - return true, nil - }) -} - -// UnbindEnvironmentProfile removes only the selected environment-aware -// binding. It is idempotent and prunes empty project, environment, and -// workspace objects. Profile definitions and legacy bindings are untouched. -func UnbindEnvironmentProfile( - root, projectName, environment string, - domain Domain, - backend string, -) error { - canonicalRoot, err := canonicalBindingRoot(root) - if err != nil { - return err - } - environment, err = validateBindingEnvironment(environment) - if err != nil { - return err - } - projectName, err = validateBindingMetadata("project name", projectName, true) - if err != nil { - return err - } - if err := validateBackend(domain, backend); err != nil { - return err - } - - bindingsMu.Lock() - defer bindingsMu.Unlock() - - path, err := BindingsPath() - if err != nil { - return err - } - return updateBindingsAt(context.Background(), path, func(bindings *bindingsFile) (bool, error) { - workspace, ok := bindings.Workspaces[canonicalRoot] - if !ok { - return false, nil - } - selection, ok := workspace.Environments[environment] - if !ok { - return false, nil - } - sectionKey := SectionKey(domain, backend) - changed := false - if projectName == "" { - if _, ok := selection.Profiles[sectionKey]; ok { - delete(selection.Profiles, sectionKey) - changed = true - } - } else if project, ok := selection.Projects[projectName]; ok { - if _, ok := project.Profiles[sectionKey]; ok { - delete(project.Profiles, sectionKey) - changed = true - } - if len(project.Profiles) == 0 { - delete(selection.Projects, projectName) - } else { - selection.Projects[projectName] = project - } - } - if !changed { - return false, nil - } - if len(selection.Profiles) == 0 && len(selection.Projects) == 0 { - delete(workspace.Environments, environment) - } else { - workspace.Environments[environment] = selection - } - if len(workspace.Environments) == 0 { - delete(bindings.Workspaces, canonicalRoot) - } else { - bindings.Workspaces[canonicalRoot] = workspace - } - return true, nil - }) -} - -type environmentProfileBindingReference struct { - WorkspaceRoot string `json:"workspaceRoot"` - Environment string `json:"environment"` - Project string `json:"project,omitempty"` -} - -// withEnvironmentProfileBindingReferences reads every environment-aware -// reference to one typed Profile and holds the store's shared process + file -// locks until inspect returns. Remove performs its config Save inside inspect: -// a concurrent binder cannot slip between the precondition and deletion. -func withEnvironmentProfileBindingReferences( - domain Domain, backend, name string, - inspect func([]environmentProfileBindingReference) error, -) (err error) { - if err := validateBackend(domain, backend); err != nil { - return err - } - if err := ValidateName(name); err != nil { - return err - } - if inspect == nil { - return errors.New("profile bindings: reference inspector is required") - } - - bindingsMu.RLock() - defer bindingsMu.RUnlock() - - path, err := BindingsPath() - if err != nil { - return err - } - release, err := acquireBindingsFileLock(context.Background(), path, false) - if err != nil { - return err - } - defer func() { - if releaseErr := release(); releaseErr != nil { - err = errors.Join(err, releaseErr) - } - }() - bindings, err := loadBindingsAt(path) - if err != nil { - return err - } - sectionKey := SectionKey(domain, backend) - references := make([]environmentProfileBindingReference, 0) - for root, workspace := range bindings.Workspaces { - for environmentName, selection := range workspace.Environments { - if selection.Profiles[sectionKey] == name { - references = append(references, environmentProfileBindingReference{ - WorkspaceRoot: root, - Environment: environmentName, - }) - } - for projectName, project := range selection.Projects { - if project.Profiles[sectionKey] == name { - references = append(references, environmentProfileBindingReference{ - WorkspaceRoot: root, - Environment: environmentName, - Project: projectName, - }) - } - } - } - } - sort.Slice(references, func(i, j int) bool { - left := references[i] - right := references[j] - if left.WorkspaceRoot != right.WorkspaceRoot { - return left.WorkspaceRoot < right.WorkspaceRoot - } - if left.Environment != right.Environment { - return left.Environment < right.Environment - } - return left.Project < right.Project - }) - return inspect(references) -} - -// updateBindingsAt holds an exclusive cross-process lock for the complete -// read-modify-write transaction. Each call constructs an independent flock -// value so separate one serve processes coordinate through the sibling lock -// file rather than relying on process memory. -func updateBindingsAt( - ctx context.Context, - path string, - mutate func(*bindingsFile) (bool, error), -) (err error) { - if mutate == nil { - return errors.New("profile bindings: mutate function is required") - } - release, err := acquireBindingsFileLock(ctx, path, true) - if err != nil { - return err - } - defer func() { - err = errors.Join(err, release()) - }() - - bindings, err := loadBindingsAt(path) - if err != nil { - return err - } - changed, err := mutate(bindings) - if err != nil || !changed { - return err - } - return saveBindingsAt(bindings, path) -} - -// readBindingsAt prevents a reader from observing the destination between a -// competing process's RMW load and atomic publication. Shared locks allow -// independent readers to proceed concurrently. -func readBindingsAt(ctx context.Context, path string) (bindings *bindingsFile, err error) { - release, err := acquireBindingsFileLock(ctx, path, false) - if err != nil { - return nil, err - } - defer func() { - err = errors.Join(err, release()) - }() - return loadBindingsAt(path) -} - -func acquireBindingsFileLock( - ctx context.Context, - path string, - exclusive bool, -) (func() error, error) { - if strings.TrimSpace(path) == "" || strings.ContainsRune(path, 0) { - return nil, errors.New("profile bindings: path is required") - } - if ctx == nil { - ctx = context.Background() - } - lockContext := ctx - cancel := func() {} - if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > bindingsLockTimeout { - lockContext, cancel = context.WithTimeout(ctx, bindingsLockTimeout) - } - defer cancel() - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return nil, fmt.Errorf("create profile bindings directory: %w", err) - } - if err := os.Chmod(dir, 0o700); err != nil { - return nil, fmt.Errorf("secure profile bindings directory: %w", err) - } - - fileLock := flock.New(path + ".lock") - var locked bool - var err error - if exclusive { - locked, err = fileLock.TryLockContext(lockContext, bindingsLockRetryDelay) - } else { - locked, err = fileLock.TryRLockContext(lockContext, bindingsLockRetryDelay) - } - if err != nil { - return nil, fmt.Errorf("lock profile bindings: %w", err) - } - if !locked { - lockErr := lockContext.Err() - if lockErr == nil { - lockErr = errors.New("lock was not acquired") - } - return nil, fmt.Errorf("lock profile bindings: %w", lockErr) - } - if err := os.Chmod(fileLock.Path(), 0o600); err != nil { - _ = fileLock.Unlock() - return nil, fmt.Errorf("secure profile bindings lock: %w", err) - } - return func() error { - if err := fileLock.Unlock(); err != nil { - return fmt.Errorf("unlock profile bindings: %w", err) - } - return nil - }, nil -} - -func loadBindingsAt(path string) (*bindingsFile, error) { - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return &bindingsFile{Version: bindingsSchemaVersion}, nil - } - return nil, err - } - var probe struct { - Version int `json:"version"` - } - if err := json.Unmarshal(raw, &probe); err != nil { - return nil, invalidBindingsFile(path, err) - } - if probe.Version != bindingsSchemaVersion { - return nil, cliErrors.New(cliErrors.PROFILE_VERSION_UNSUPPORTED, - fmt.Sprintf("profile-bindings.json schema version 不支持:要求 v%d,当前 v%d", bindingsSchemaVersion, probe.Version)). - WithContext(map[string]any{"path": path, "version": probe.Version}) - } - var bindings bindingsFile - if err := json.Unmarshal(raw, &bindings); err != nil { - return nil, invalidBindingsFile(path, err) - } - if bindings.Workspaces == nil { - bindings.Workspaces = make(map[string]bindingsWorkspace) - } - return &bindings, nil -} - -func invalidBindingsFile(path string, err error) error { - return cliErrors.New(cliErrors.PROFILE_FILE_INVALID, - "~/.config/one/profile-bindings.json 解析失败:"+err.Error()). - WithContext(map[string]any{"path": path}) -} - -func saveBindingsAt(bindings *bindingsFile, path string) error { - if bindings == nil { - return errors.New("profile: nil bindings") - } - bindings.Version = bindingsSchemaVersion - if len(bindings.Workspaces) == 0 { - bindings.Workspaces = nil - } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - return atomicWriteSynced(bindings, path) -} - -// atomicWriteSynced makes the binding update durable without exposing a -// partially-written JSON document: write a sibling temp file, force its mode, -// fsync it, rename it over the destination, then fsync the parent directory. -func atomicWriteSynced(value any, path string) error { - raw, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, ".profile-bindings-*.json") - if err != nil { - return err - } - tmpPath := tmp.Name() - closed := false - defer func() { - if !closed { - _ = tmp.Close() - } - _ = os.Remove(tmpPath) - }() - if _, err := tmp.Write(raw); err != nil { - return err - } - if err := tmp.Chmod(0o600); err != nil { - return err - } - if err := tmp.Sync(); err != nil { - return err - } - if err := tmp.Close(); err != nil { - closed = true - return err - } - closed = true - if err := fsutil.ReplaceFile(tmpPath, path); err != nil { - return err - } - return fsutil.SyncDir(dir) -} - -func canonicalBindingRoot(root string) (string, error) { - if root != strings.TrimSpace(root) || root == "" || strings.ContainsRune(root, 0) { - return "", invalidBindingValue("workspace root", root) - } - abs, err := filepath.Abs(root) - if err != nil { - return "", invalidBindingValue("workspace root", root) - } - abs = filepath.Clean(abs) - info, err := os.Stat(abs) - if err != nil || !info.IsDir() { - return "", invalidBindingValue("workspace root", root) - } - canonical, err := filepath.EvalSymlinks(abs) - if err != nil { - return "", invalidBindingValue("workspace root", root) - } - return filepath.Clean(canonical), nil -} - -func validateBindingEnvironment(environment string) (string, error) { - if environment != strings.TrimSpace(environment) || len(environment) > 128 || - !bindingIdentifierRE.MatchString(environment) { - return "", invalidBindingValue("environment", environment) - } - return environment, nil -} - -func validateBindingProfileName(name string) (string, error) { - if err := ValidateName(name); err != nil { - return "", err - } - return name, nil -} - -func validateBindingMetadata(field, value string, optional bool) (string, error) { - if value != strings.TrimSpace(value) || len(value) > 256 { - return "", invalidBindingValue(field, value) - } - if value == "" && optional { - return "", nil - } - for _, char := range value { - if unicode.IsControl(char) { - return "", invalidBindingValue(field, value) - } - } - return value, nil -} - -func invalidBindingValue(field, value string) error { - return cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("%s 不合法。", field)). - WithContext(map[string]any{"field": field, "value": value}) -} - -func environmentBindingNamesAt( - root, environment, projectName, sectionKey string, -) (projectNameResult, workspaceNameResult string, err error) { - bindings, err := environmentBindings(root, environment) - if err != nil || bindings == nil { - return "", "", err - } - workspaceNameResult = strings.TrimSpace(bindings.Profiles[sectionKey]) - projectName = strings.TrimSpace(projectName) - if projectName == "" { - return "", workspaceNameResult, nil - } - project, ok := bindings.Projects[projectName] - if !ok { - return "", workspaceNameResult, nil - } - return strings.TrimSpace(project.Profiles[sectionKey]), workspaceNameResult, nil -} - -func environmentBindings(root, environment string) (*bindingsEnvironment, error) { - canonicalRoot, err := canonicalBindingRoot(root) - if err != nil { - return nil, err - } - environment, err = validateBindingEnvironment(environment) - if err != nil { - return nil, err - } - bindingsMu.RLock() - defer bindingsMu.RUnlock() - path, err := BindingsPath() - if err != nil { - return nil, err - } - bindings, err := readBindingsAt(context.Background(), path) - if err != nil { - return nil, err - } - workspace, ok := bindings.Workspaces[canonicalRoot] - if !ok { - return nil, nil - } - selection, ok := workspace.Environments[environment] - if !ok { - return nil, nil - } - return &selection, nil -} diff --git a/packages/cli/internal/core/profile/bindings_test.go b/packages/cli/internal/core/profile/bindings_test.go deleted file mode 100644 index a1488d4d..00000000 --- a/packages/cli/internal/core/profile/bindings_test.go +++ /dev/null @@ -1,690 +0,0 @@ -package profile - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/gofrs/flock" -) - -func seedInfisicalProfiles(t *testing.T, names ...string) { - t.Helper() - for _, name := range names { - if _, err := Upsert(DomainEnv, "infisical", name, Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "id-" + name, ClientSecret: "secret-" + name}, - }, - }, false); err != nil { - t.Fatalf("seed profile %q: %v", name, err) - } - } -} - -func resolveEnvironmentProfile( - t *testing.T, root, project, environment string, -) *Resolved { - t.Helper() - resolved, err := Resolve(ResolveInput{ - Domain: DomainEnv, - Backend: "infisical", - WorkspaceID: "same-shared-id", - WorkspaceRoot: root, - ProjectName: project, - Environment: environment, - }) - if err != nil { - t.Fatalf("resolve %s/%s: %v", environment, project, err) - } - return resolved -} - -func TestEnvironmentBindingsKeepThreeEnvironmentsIndependent(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "default", "development", "previewing", "production") - - for environment, name := range map[string]string{ - "dev": "development", "preview": "previewing", "prod": "production", - } { - if err := BindEnvironmentProfile( - "same-shared-id", "demo", root, "", environment, - DomainEnv, "infisical", name, - ); err != nil { - t.Fatalf("bind %s: %v", environment, err) - } - } - - for environment, want := range map[string]string{ - "dev": "development", "preview": "previewing", "prod": "production", - } { - resolved := resolveEnvironmentProfile(t, root, "", environment) - if resolved.Name != want || resolved.Source != "workspace-environment" { - t.Fatalf("resolve %s = %q (%s), want %q (workspace-environment)", - environment, resolved.Name, resolved.Source, want) - } - } -} - -func TestEnvironmentBindingPrecedenceAndLegacyFallback(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "default", "legacy-workspace", "legacy-project", "environment-workspace", "environment-project", "flag") - if err := BindWorkspaceProfile( - "same-shared-id", "demo", root, "", DomainEnv, "infisical", "legacy-workspace", - ); err != nil { - t.Fatal(err) - } - if err := BindWorkspaceProfile( - "same-shared-id", "demo", root, "web", DomainEnv, "infisical", "legacy-project", - ); err != nil { - t.Fatal(err) - } - if err := BindEnvironmentProfile( - "same-shared-id", "demo", root, "", "dev", DomainEnv, "infisical", "environment-workspace", - ); err != nil { - t.Fatal(err) - } - - resolved := resolveEnvironmentProfile(t, root, "web", "dev") - if resolved.Name != "environment-workspace" || resolved.Source != "workspace-environment" { - t.Fatalf("environment workspace did not beat legacy project: %#v", resolved) - } - if err := BindEnvironmentProfile( - "same-shared-id", "demo", root, "web", "dev", DomainEnv, "infisical", "environment-project", - ); err != nil { - t.Fatal(err) - } - resolved = resolveEnvironmentProfile(t, root, "web", "dev") - if resolved.Name != "environment-project" || resolved.Source != "workspace-project-environment" { - t.Fatalf("environment project did not win: %#v", resolved) - } - flagged, err := Resolve(ResolveInput{ - Domain: DomainEnv, Backend: "infisical", FlagOverride: "flag", - WorkspaceID: "same-shared-id", WorkspaceRoot: root, ProjectName: "web", Environment: "dev", - }) - if err != nil { - t.Fatal(err) - } - if flagged.Name != "flag" || flagged.Source != "flag" { - t.Fatalf("flag did not win: %#v", flagged) - } - - if err := UnbindEnvironmentProfile(root, "web", "dev", DomainEnv, "infisical"); err != nil { - t.Fatal(err) - } - if err := UnbindEnvironmentProfile(root, "", "dev", DomainEnv, "infisical"); err != nil { - t.Fatal(err) - } - resolved = resolveEnvironmentProfile(t, root, "web", "dev") - if resolved.Name != "legacy-project" || resolved.Source != "workspace-project" { - t.Fatalf("legacy project fallback lost: %#v", resolved) - } -} - -func TestEnvironmentBindingsUseCanonicalRootInsteadOfSharedWorkspaceID(t *testing.T) { - withIsolatedConfig(t) - firstRoot := t.TempDir() - secondRoot := t.TempDir() - seedInfisicalProfiles(t, "default", "first-copy", "second-copy") - - for _, binding := range []struct{ root, name string }{ - {firstRoot, "first-copy"}, {secondRoot, "second-copy"}, - } { - if err := BindEnvironmentProfile( - "same-shared-id", "demo", binding.root, "", "preview", - DomainEnv, "infisical", binding.name, - ); err != nil { - t.Fatal(err) - } - } - if got := resolveEnvironmentProfile(t, firstRoot, "", "preview").Name; got != "first-copy" { - t.Fatalf("first checkout resolved %q", got) - } - if got := resolveEnvironmentProfile(t, secondRoot, "", "preview").Name; got != "second-copy" { - t.Fatalf("second checkout resolved %q", got) - } -} - -func TestEnvironmentBindingUnbindPrunesEmptyHierarchy(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "default", "workspace", "project") - if err := BindEnvironmentProfile( - "workspace-id", "demo", root, "", "prod", DomainEnv, "infisical", "workspace", - ); err != nil { - t.Fatal(err) - } - if err := BindEnvironmentProfile( - "workspace-id", "demo", root, "web", "prod", DomainEnv, "infisical", "project", - ); err != nil { - t.Fatal(err) - } - if err := UnbindEnvironmentProfile(root, "web", "prod", DomainEnv, "infisical"); err != nil { - t.Fatal(err) - } - if got := resolveEnvironmentProfile(t, root, "web", "prod"); got.Name != "workspace" { - t.Fatalf("project unbind did not fall back to workspace: %#v", got) - } - if err := UnbindEnvironmentProfile(root, "", "prod", DomainEnv, "infisical"); err != nil { - t.Fatal(err) - } - - path, err := BindingsPath() - if err != nil { - t.Fatal(err) - } - bindings, err := loadBindingsAt(path) - if err != nil { - t.Fatal(err) - } - if bindings.Version != bindingsSchemaVersion || len(bindings.Workspaces) != 0 { - t.Fatalf("empty hierarchy was not pruned: %#v", bindings) - } - // Repeating the operation remains a no-op. - if err := UnbindEnvironmentProfile(root, "", "prod", DomainEnv, "infisical"); err != nil { - t.Fatalf("idempotent unbind: %v", err) - } -} - -func TestEnvironmentProfileBindingReadsStaleDirectSelectionUntilExplicitUnbind(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "stale", "fallback") - if err := SetDefault(DomainEnv, "infisical", "fallback"); err != nil { - t.Fatal(err) - } - if err := BindEnvironmentProfile( - "workspace-id", "demo", root, "web", "preview", - DomainEnv, "infisical", "stale", - ); err != nil { - t.Fatal(err) - } - - // Simulate an existing stale store produced by an older client or a manual - // config edit. The current Remove path has a separate cleanup test below. - cfg, _, err := Load() - if err != nil { - t.Fatal(err) - } - delete(cfg.EnvInfisical.Profiles, "stale") - if err := Save(cfg); err != nil { - t.Fatal(err) - } - - got, err := EnvironmentProfileBinding( - root, "web", "preview", DomainEnv, "infisical", - ) - if err != nil { - t.Fatal(err) - } - if got != "stale" { - t.Fatalf("raw direct binding = %q, want stale", got) - } - if _, err := Resolve(ResolveInput{ - Domain: DomainEnv, Backend: "infisical", WorkspaceRoot: root, - ProjectName: "web", Environment: "preview", - }); err == nil { - t.Fatal("stale direct binding unexpectedly resolved as a usable Profile") - } - - if err := UnbindEnvironmentProfile( - root, "web", "preview", DomainEnv, "infisical", - ); err != nil { - t.Fatal(err) - } - got, err = EnvironmentProfileBinding( - root, "web", "preview", DomainEnv, "infisical", - ) - if err != nil { - t.Fatal(err) - } - if got != "" { - t.Fatalf("raw direct binding after unbind = %q", got) - } - resolved := resolveEnvironmentProfile(t, root, "web", "preview") - if resolved.Name != "fallback" || resolved.Source != "default" { - t.Fatalf("unbind did not restore fallback: %#v", resolved) - } -} - -func TestEnvironmentBindRevalidatesProfileAfterWaitingForStoreLock(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "removed-before-commit") - bindingsPath, err := BindingsPath() - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Dir(bindingsPath), 0o700); err != nil { - t.Fatal(err) - } - externalLock := flock.New(bindingsPath + ".lock") - if err := externalLock.Lock(); err != nil { - t.Fatal(err) - } - - bindResult := make(chan error, 1) - go func() { - bindResult <- BindEnvironmentProfile( - "workspace-id", "demo", root, "web", "preview", - DomainEnv, "infisical", "removed-before-commit", - ) - }() - - // Remove the definition while the binding publication is unable to acquire - // its file lock. Once unblocked, Bind must reload config and reject instead - // of publishing a stale name based on an earlier validation. - cfg, _, err := Load() - if err != nil { - t.Fatal(err) - } - delete(cfg.EnvInfisical.Profiles, "removed-before-commit") - cfg.EnvInfisical.Default = "" - if err := Save(cfg); err != nil { - t.Fatal(err) - } - if err := externalLock.Unlock(); err != nil { - t.Fatal(err) - } - - select { - case err := <-bindResult: - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != "PROFILE_NOT_FOUND" { - t.Fatalf("bind error = %v, want PROFILE_NOT_FOUND", err) - } - case <-time.After(3 * time.Second): - t.Fatal("binding did not finish after the file lock was released") - } - got, err := EnvironmentProfileBinding( - root, "web", "preview", DomainEnv, "infisical", - ) - if err != nil { - t.Fatal(err) - } - if got != "" { - t.Fatalf("queued binding published stale Profile %q", got) - } -} - -func TestEnvironmentBindingWritesOnlyPrivateMachineLocalFile(t *testing.T) { - configHome := withIsolatedConfig(t) - root := t.TempDir() - manifestPath := filepath.Join(root, "one.manifest.json") - manifest := []byte(`{"schema":"one-cli/manifest/v1","workspace":{"id":"workspace-id"}}`) - if err := os.WriteFile(manifestPath, manifest, 0o644); err != nil { - t.Fatal(err) - } - seedInfisicalProfiles(t, "default", "selected") - configPath, credentialsPath := cfgPaths(configHome) - configBefore, err := os.ReadFile(configPath) - if err != nil { - t.Fatal(err) - } - credentialsBefore, err := os.ReadFile(credentialsPath) - if err != nil { - t.Fatal(err) - } - - if err := BindEnvironmentProfile( - "workspace-id", "demo", root, "", "dev", DomainEnv, "infisical", "selected", - ); err != nil { - t.Fatal(err) - } - manifestAfter, _ := os.ReadFile(manifestPath) - configAfter, _ := os.ReadFile(configPath) - credentialsAfter, _ := os.ReadFile(credentialsPath) - if string(manifestAfter) != string(manifest) { - t.Fatal("binding mutated one.manifest.json") - } - if string(configAfter) != string(configBefore) { - t.Fatal("binding mutated config.json") - } - if string(credentialsAfter) != string(credentialsBefore) { - t.Fatal("binding mutated credentials.json") - } - - path, err := BindingsPath() - if err != nil { - t.Fatal(err) - } - wantPath := filepath.Join(configHome, "one", "profile-bindings.json") - if path != wantPath { - t.Fatalf("bindings path = %q, want %q", path, wantPath) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { - t.Fatalf("bindings mode = %04o, want 0600", info.Mode().Perm()) - } - var disk map[string]any - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(raw, &disk); err != nil { - t.Fatalf("invalid bindings JSON: %v", err) - } - if disk["version"] != float64(1) { - t.Fatalf("bindings version = %#v", disk["version"]) - } - if _, err := os.Stat(filepath.Join(root, "profile-bindings.json")); !os.IsNotExist(err) { - t.Fatalf("binding file was written inside workspace: %v", err) - } -} - -func TestEnvironmentBindingValidationRejectsAmbiguousKeys(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "default", "selected") - tests := []struct { - name string - root string - environment string - profile string - }{ - {name: "missing root", root: filepath.Join(root, "missing"), environment: "dev", profile: "selected"}, - {name: "padded root", root: " " + root, environment: "dev", profile: "selected"}, - {name: "padded environment", root: root, environment: " dev", profile: "selected"}, - {name: "path-like environment", root: root, environment: "team/dev", profile: "selected"}, - {name: "leading dash environment", root: root, environment: "-dev", profile: "selected"}, - {name: "empty profile", root: root, environment: "dev", profile: ""}, - {name: "path-like profile", root: root, environment: "dev", profile: "team/selected"}, - {name: "spaced profile", root: root, environment: "dev", profile: "team selected"}, - {name: "leading dash profile", root: root, environment: "dev", profile: "-selected"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := BindEnvironmentProfile( - "workspace-id", "demo", test.root, "", test.environment, - DomainEnv, "infisical", test.profile, - ) - if err == nil { - t.Fatal("expected validation error") - } - }) - } -} - -func TestEnvironmentBindingAcceptsCustomSafeEnvironmentID(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "selected") - if err := BindEnvironmentProfile( - "workspace-id", "demo", root, "", "staging_us2", - DomainEnv, "infisical", "selected", - ); err != nil { - t.Fatalf("bind custom environment: %v", err) - } - resolved := resolveEnvironmentProfile(t, root, "", "staging_us2") - if resolved.Name != "selected" || resolved.Source != "workspace-environment" { - t.Fatalf("custom environment resolution: %#v", resolved) - } -} - -func TestEnvironmentBindingMutexPreventsLostInProcessUpdates(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "selected") - const count = 24 - var wait sync.WaitGroup - errs := make(chan error, count) - for index := 0; index < count; index++ { - wait.Add(1) - go func(index int) { - defer wait.Done() - errs <- BindEnvironmentProfile( - "workspace-id", "demo", root, "project-"+string(rune('a'+index)), "dev", - DomainEnv, "infisical", "selected", - ) - }(index) - } - wait.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatal(err) - } - } - path, _ := BindingsPath() - bindings, err := loadBindingsAt(path) - if err != nil { - t.Fatal(err) - } - canonical, _ := canonicalBindingRoot(root) - if got := len(bindings.Workspaces[canonical].Environments["dev"].Projects); got != count { - t.Fatalf("project bindings = %d, want %d", got, count) - } -} - -func TestBindingFileLockPreventsLostIndependentTransactionUpdates(t *testing.T) { - path := filepath.Join(t.TempDir(), "profile-bindings.json") - const count = 16 - start := make(chan struct{}) - errs := make(chan error, count) - var wait sync.WaitGroup - - // updateBindingsAt intentionally has no dependency on bindingsMu. Each call - // creates its own flock value, matching independent one serve processes. - for index := 0; index < count; index++ { - wait.Add(1) - go func(index int) { - defer wait.Done() - <-start - errs <- updateBindingsAt(context.Background(), path, func(bindings *bindingsFile) (bool, error) { - // Widen the RMW window: without the file lock, every writer can - // load the same version and publish over another writer. - time.Sleep(2 * time.Millisecond) - if bindings.Workspaces == nil { - bindings.Workspaces = make(map[string]bindingsWorkspace) - } - key := fmt.Sprintf("/workspace/copy-%02d", index) - bindings.Workspaces[key] = bindingsWorkspace{Name: key} - return true, nil - }) - }(index) - } - close(start) - wait.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatal(err) - } - } - - bindings, err := loadBindingsAt(path) - if err != nil { - t.Fatal(err) - } - if got := len(bindings.Workspaces); got != count { - t.Fatalf("workspace bindings = %d, want %d", got, count) - } -} - -func TestBindingFileLockSerializesIndependentBindAndUnbindTransactions(t *testing.T) { - path := filepath.Join(t.TempDir(), "profile-bindings.json") - rootKey := "/workspace/demo" - sectionKey := SectionKey(DomainEnv, "infisical") - if err := saveBindingsAt(&bindingsFile{ - Version: bindingsSchemaVersion, - Workspaces: map[string]bindingsWorkspace{ - rootKey: { - Environments: map[string]bindingsEnvironment{ - "dev": { - Projects: map[string]bindingsProjectProfile{ - "old-project": {Profiles: map[string]string{sectionKey: "old"}}, - }, - }, - }, - }, - }, - }, path); err != nil { - t.Fatal(err) - } - - start := make(chan struct{}) - errs := make(chan error, 2) - var wait sync.WaitGroup - wait.Add(2) - go func() { - defer wait.Done() - <-start - errs <- updateBindingsAt(context.Background(), path, func(bindings *bindingsFile) (bool, error) { - workspace := bindings.Workspaces[rootKey] - selection := workspace.Environments["dev"] - time.Sleep(20 * time.Millisecond) - if selection.Projects == nil { - selection.Projects = make(map[string]bindingsProjectProfile) - } - selection.Projects["new-project"] = bindingsProjectProfile{ - Profiles: map[string]string{sectionKey: "new"}, - } - workspace.Environments["dev"] = selection - bindings.Workspaces[rootKey] = workspace - return true, nil - }) - }() - go func() { - defer wait.Done() - <-start - errs <- updateBindingsAt(context.Background(), path, func(bindings *bindingsFile) (bool, error) { - workspace := bindings.Workspaces[rootKey] - selection := workspace.Environments["dev"] - time.Sleep(20 * time.Millisecond) - delete(selection.Projects, "old-project") - workspace.Environments["dev"] = selection - bindings.Workspaces[rootKey] = workspace - return true, nil - }) - }() - close(start) - wait.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatal(err) - } - } - - bindings, err := loadBindingsAt(path) - if err != nil { - t.Fatal(err) - } - projects := bindings.Workspaces[rootKey].Environments["dev"].Projects - if _, ok := projects["new-project"]; !ok { - t.Fatal("concurrent unbind lost the independent bind update") - } - if _, ok := projects["old-project"]; ok { - t.Fatal("concurrent bind lost the independent unbind update") - } -} - -func TestBindingFileReadsShareLockWhileWriterHonorsDeadline(t *testing.T) { - path := filepath.Join(t.TempDir(), "profile-bindings.json") - if err := saveBindingsAt(&bindingsFile{Version: bindingsSchemaVersion}, path); err != nil { - t.Fatal(err) - } - - // This separate flock value represents a reader in another process. - externalReader := flock.New(path + ".lock") - if err := externalReader.RLock(); err != nil { - t.Fatal(err) - } - locked := true - defer func() { - if locked { - _ = externalReader.Unlock() - } - }() - - // Another shared reader must not be blocked by the external reader. - if _, err := readBindingsAt(context.Background(), path); err != nil { - t.Fatalf("shared read behind shared lock: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - mutated := false - err := updateBindingsAt(ctx, path, func(*bindingsFile) (bool, error) { - mutated = true - return true, nil - }) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("exclusive write error = %v, want context deadline exceeded", err) - } - if mutated { - t.Fatal("writer mutated state without acquiring its exclusive lock") - } - if err := externalReader.Unlock(); err != nil { - t.Fatal(err) - } - locked = false - - if runtime.GOOS != "windows" { - info, err := os.Stat(path + ".lock") - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0o600 { - t.Fatalf("bindings lock mode = %04o, want 0600", info.Mode().Perm()) - } - } -} - -func TestAtomicBindingMarshalFailurePreservesExistingFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "profile-bindings.json") - original := []byte(`{"version":1}`) - if err := os.WriteFile(path, original, 0o600); err != nil { - t.Fatal(err) - } - if err := atomicWriteSynced(make(chan int), path); err == nil { - t.Fatal("expected marshal failure") - } - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(after) != string(original) { - t.Fatalf("failed atomic write changed destination: %q", after) - } -} - -func TestAtomicBindingRenameFailureCleansSiblingTempFile(t *testing.T) { - dir := t.TempDir() - // A non-empty destination directory makes rename fail after the sibling - // temp file has been written and synced, without relying on file modes (the - // test suite may run as a privileged user). - destination := filepath.Join(dir, "profile-bindings.json") - if err := os.Mkdir(destination, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(destination, "keep"), []byte("keep"), 0o600); err != nil { - t.Fatal(err) - } - if err := atomicWriteSynced(&bindingsFile{Version: 1}, destination); err == nil { - t.Fatal("expected rename failure") - } - if raw, err := os.ReadFile(filepath.Join(destination, "keep")); err != nil || string(raw) != "keep" { - t.Fatalf("rename failure changed destination: raw=%q err=%v", raw, err) - } - matches, err := filepath.Glob(filepath.Join(dir, ".profile-bindings-*.json")) - if err != nil { - t.Fatal(err) - } - if len(matches) != 0 { - t.Fatalf("temporary binding files leaked after failure: %v", matches) - } -} diff --git a/packages/cli/internal/core/profile/cache.go b/packages/cli/internal/core/profile/cache.go deleted file mode 100644 index dda54d3f..00000000 --- a/packages/cli/internal/core/profile/cache.go +++ /dev/null @@ -1,123 +0,0 @@ -package profile - -// cache.go is the short-lived-token cache for backends that exchange -// long-term credentials (file-source AKID/secret) for an OIDC-style -// session token. Today only the Infisical Universal-Auth login uses -// it; the layer is generic so SSO / `credential_process` integrations -// can reuse it later. -// -// Layout: ~/.config/one/cache///.json -// Mode: 0600 per file, 0700 for parent dirs. -// -// Persistent (long-term) credentials live in ~/.config/one/credentials.json -// — the cache is intentionally a separate directory so it can be wiped -// without losing the user's profile config (`rm -rf ~/.config/one/cache`). - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "time" -) - -// cacheClockSkew is the buffer subtracted from a cache entry's -// ExpiresAt before deciding it's still good. 60s avoids returning a -// token that expires mid-flight. -const cacheClockSkew = 60 * time.Second - -// CacheEntry is one cached short-lived token. Wire format intentionally -// minimal — additional fields (e.g. refresh_token, issuer) can be -// added later without breaking back-compat because unknown fields are -// ignored on decode. -type CacheEntry struct { - Token string `json:"token"` - TokenType string `json:"tokenType,omitempty"` - ExpiresAt time.Time `json:"expiresAt"` - SavedAt time.Time `json:"savedAt"` -} - -// IsExpired reports whether the entry should not be reused given the -// cacheClockSkew buffer. -func (e *CacheEntry) IsExpired(now time.Time) bool { - if e == nil { - return true - } - return now.Add(cacheClockSkew).After(e.ExpiresAt) -} - -// CachePath returns the cache file path for one (domain, backend, -// profile) triple. Does not create the file. -func CachePath(domain Domain, backend, name string) (string, error) { - if err := ValidateName(name); err != nil { - return "", err - } - root, err := CacheDir() - if err != nil { - return "", err - } - return filepath.Join(root, string(domain), backend, name+".json"), nil -} - -// ReadCache returns the parsed cache entry or nil when: -// - the file does not exist; -// - the file exists but fails to parse; -// - the entry has expired (per IsExpired). -// -// All three "no usable token" conditions are conflated into (nil, nil) -// so callers always know what to do: fall through to a fresh login. -// Real I/O errors (permission, disk) still surface as non-nil err. -func ReadCache(domain Domain, backend, name string) (*CacheEntry, error) { - path, err := CachePath(domain, backend, name) - if err != nil { - return nil, err - } - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, nil - } - return nil, err - } - var entry CacheEntry - if err := json.Unmarshal(raw, &entry); err != nil { - // Corrupted cache — pretend it's not there. - return nil, nil - } - if entry.IsExpired(time.Now().UTC()) { - return nil, nil - } - return &entry, nil -} - -// WriteCache atomically persists entry as the cache for (domain, -// backend, name). Creates parent dirs at 0700 and writes the file at -// 0600. -func WriteCache(domain Domain, backend, name string, entry *CacheEntry) error { - path, err := CachePath(domain, backend, name) - if err != nil { - return err - } - if entry == nil { - return errors.New("profile: nil cache entry") - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - return atomicWrite(entry, path) -} - -// ClearCache deletes the cache file for (domain, backend, name). -// Missing file is not an error — the function is meant to be called -// best-effort during profile remove / login failure paths. -func ClearCache(domain Domain, backend, name string) error { - path, err := CachePath(domain, backend, name) - if err != nil { - return err - } - if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - return nil -} diff --git a/packages/cli/internal/core/profile/cache_test.go b/packages/cli/internal/core/profile/cache_test.go deleted file mode 100644 index 83ff57ce..00000000 --- a/packages/cli/internal/core/profile/cache_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package profile - -import ( - "os" - "path/filepath" - "runtime" - "testing" - "time" -) - -// Round-trip a non-expired entry through Write/Read. -func TestCache_WriteRead(t *testing.T) { - withIsolatedConfig(t) - now := time.Now().UTC() - entry := &CacheEntry{ - Token: "abc.def.ghi", - TokenType: "Bearer", - ExpiresAt: now.Add(2 * time.Hour), - SavedAt: now, - } - if err := WriteCache(DomainEnv, "infisical", "work", entry); err != nil { - t.Fatalf("write: %v", err) - } - got, err := ReadCache(DomainEnv, "infisical", "work") - if err != nil { - t.Fatalf("read: %v", err) - } - if got == nil { - t.Fatalf("read returned nil; want hit") - } - if got.Token != entry.Token || got.TokenType != entry.TokenType { - t.Errorf("round-trip lost fields: %+v", got) - } -} - -// Expired entries return (nil, nil) so callers fall through to login. -func TestCache_ExpiredReturnsMiss(t *testing.T) { - withIsolatedConfig(t) - if err := WriteCache(DomainEnv, "infisical", "work", &CacheEntry{ - Token: "expired-token", - ExpiresAt: time.Now().Add(-time.Hour), - SavedAt: time.Now().Add(-2 * time.Hour), - }); err != nil { - t.Fatalf("write: %v", err) - } - got, err := ReadCache(DomainEnv, "infisical", "work") - if err != nil { - t.Fatalf("read: %v", err) - } - if got != nil { - t.Errorf("expected expired→nil, got %+v", got) - } -} - -// Entry one second from expiring is treated as expired (60s skew buffer). -func TestCache_NearExpiryWithinSkewMisses(t *testing.T) { - withIsolatedConfig(t) - if err := WriteCache(DomainEnv, "infisical", "work", &CacheEntry{ - Token: "almost", - ExpiresAt: time.Now().Add(30 * time.Second), - }); err != nil { - t.Fatalf("write: %v", err) - } - got, _ := ReadCache(DomainEnv, "infisical", "work") - if got != nil { - t.Errorf("near-expiry should be treated as miss: %+v", got) - } -} - -// Cache files must be 0600. -func TestCache_FileMode(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Windows ACLs are not represented by Unix permission bits") - } - withIsolatedConfig(t) - if err := WriteCache(DomainEnv, "infisical", "work", &CacheEntry{ - Token: "x", - ExpiresAt: time.Now().Add(time.Hour), - }); err != nil { - t.Fatalf("write: %v", err) - } - path, _ := CachePath(DomainEnv, "infisical", "work") - st, err := os.Stat(path) - if err != nil { - t.Fatalf("stat: %v", err) - } - if mode := st.Mode().Perm(); mode != 0o600 { - t.Errorf("mode: got %o want 0600", mode) - } -} - -// A corrupted JSON file is treated as a miss, not an error. -func TestCache_CorruptedFileTreatedAsMiss(t *testing.T) { - withIsolatedConfig(t) - path, _ := CachePath(DomainEnv, "infisical", "work") - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { - t.Fatalf("write: %v", err) - } - got, err := ReadCache(DomainEnv, "infisical", "work") - if err != nil { - t.Fatalf("read: %v", err) - } - if got != nil { - t.Errorf("corrupted should miss; got %+v", got) - } -} - -// ClearCache is best-effort: removing a non-existent file is not an error. -func TestCache_ClearIdempotent(t *testing.T) { - withIsolatedConfig(t) - if err := ClearCache(DomainEnv, "infisical", "never-existed"); err != nil { - t.Errorf("clear on missing: %v", err) - } - if err := WriteCache(DomainEnv, "infisical", "work", &CacheEntry{ - Token: "x", - ExpiresAt: time.Now().Add(time.Hour), - }); err != nil { - t.Fatalf("write: %v", err) - } - if err := ClearCache(DomainEnv, "infisical", "work"); err != nil { - t.Errorf("clear: %v", err) - } - got, _ := ReadCache(DomainEnv, "infisical", "work") - if got != nil { - t.Errorf("entry survived clear: %+v", got) - } -} diff --git a/packages/cli/internal/core/profile/mutate.go b/packages/cli/internal/core/profile/mutate.go deleted file mode 100644 index 5e9478c1..00000000 --- a/packages/cli/internal/core/profile/mutate.go +++ /dev/null @@ -1,436 +0,0 @@ -package profile - -// mutate.go — `profile add / remove / use` operations on the on-disk -// config. Each function loads, mutates, and saves; concurrent CLI -// invocations against the same machine config can race, but the file -// is single-user per design and the worst case is one of two -// near-simultaneous edits losing — same as kubectl / aws. -// -// The storage split per (domain, backend) plus the file/credentials -// physical split: every mutator takes a backend dimension alongside -// the domain. The Profile composite is destructured at the boundary -// into the typed sub-profile that belongs in the matching Section, -// and Save handles splitting Credentials out into credentials.json. - -import ( - "fmt" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -// Add inserts a new profile under (domain, backend). Returns -// PROFILE_ALREADY_EXISTS when name is taken at that section — callers -// should route that to the user as "re-run add to update credentials" -// rather than silently overwriting. -// -// setDefault is honored only when the section currently has no default -// pointer (the first profile added becomes default automatically) OR -// the caller explicitly passes true. This matches the kubectl `--use` -// flag pattern. -// -// The Profile.Backend field is required and must match `backend`; the -// profile struct must carry the matching typed sub-profile (Infisical -// for "infisical", S3 for any S3-compatible deploy backend, etc.) — -// checked by writeProfile. -func Add(domain Domain, backend, name string, profile Profile, setDefault bool) error { - if err := ValidateName(name); err != nil { - return err - } - if err := validateBackend(domain, backend); err != nil { - return err - } - if profile.Backend != "" && profile.Backend != backend { - return cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("profile.Backend = %q 与目标 backend %q 不匹配", profile.Backend, backend)) - } - cfg, _, err := Load() - if err != nil { - return err - } - if exists, _ := profileExists(cfg, domain, backend, name); exists { - return cliErrors.New(cliErrors.PROFILE_ALREADY_EXISTS, - fmt.Sprintf("profile %q 已存在于 %s;要更新凭据请用 `one configure %s/%s add %s`。", - name, SectionKey(domain, backend), domain, backend, name)). - WithContext(map[string]any{ - "section": SectionKey(domain, backend), - "name": name, - }) - } - if err := writeProfile(cfg, domain, backend, name, profile, setDefault); err != nil { - return err - } - return Save(cfg) -} - -// Upsert inserts or replaces a profile under (domain, backend). -// Unlike Add it silently overwrites an existing profile of the same -// name — this is the "configure once, re-run to update credentials" -// semantic used by `one configure add /`. Returns -// updated=true when an existing profile was replaced, false when a -// fresh entry was created. -// -// setDefault honours the same "first profile becomes default -// automatically" rule as Add: explicit true forces default, otherwise -// default flips only when the section has no default profile yet. -func Upsert(domain Domain, backend, name string, profile Profile, setDefault bool) (updated bool, err error) { - if err := ValidateName(name); err != nil { - return false, err - } - if err := validateBackend(domain, backend); err != nil { - return false, err - } - if profile.Backend != "" && profile.Backend != backend { - return false, cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("profile.Backend = %q 与目标 backend %q 不匹配", profile.Backend, backend)) - } - cfg, _, err := Load() - if err != nil { - return false, err - } - existed, _ := profileExists(cfg, domain, backend, name) - if err := writeProfile(cfg, domain, backend, name, profile, setDefault); err != nil { - return false, err - } - if err := Save(cfg); err != nil { - return false, err - } - // Re-saving an existing profile invalidates whatever short-lived - // token we cached for it (creds may have rotated). - if existed { - _ = ClearCache(domain, backend, name) - } - return existed, nil -} - -// Remove deletes a profile from a (domain, backend) section. When -// backend is empty, the function searches across every backend in the -// domain and disambiguates: a unique match is removed; multiple -// matches return PROFILE_BACKEND_INVALID with the list of candidate -// backends so the caller can re-run with `--backend `. If the -// removed profile was default for its section, default is reset to "" -// (caller can show "no default profile; pick one with `profile use`"); -// we deliberately don't auto-pick a new default to avoid surprising -// the user. An environment-aware binding blocks deletion with PROFILE_IN_USE; -// callers must explicitly unbind it first so the independent binding store and -// Profile files never need a non-atomic cross-file cascade. -func Remove(domain Domain, backend, name string) error { - if err := ValidateName(name); err != nil { - return err - } - cfg, _, err := Load() - if err != nil { - return err - } - resolvedBackend, err := resolveBackendFromName(cfg, domain, backend, name) - if err != nil { - return err - } - policy, ok := schemaPolicy(domain, resolvedBackend) - if !ok { - return invalidProfilePair(domain, resolvedBackend) - } - err = withEnvironmentProfileBindingReferences( - domain, resolvedBackend, name, - func(references []environmentProfileBindingReference) error { - if len(references) > 0 { - return cliErrors.New(cliErrors.PROFILE_IN_USE, - fmt.Sprintf("profile %q 仍被 %d 个环境绑定引用;请先在 Dashboard 中选择 Automatic 解绑。", name, len(references))). - WithContext(map[string]any{ - "section": SectionKey(domain, resolvedBackend), - "name": name, - "binding_count": len(references), - "bindings": references, - }) - } - removeLegacyProfileBindings(cfg, domain, resolvedBackend, name) - policy.remove(cfg, name) - return Save(cfg) - }, - ) - if err != nil { - return err - } - // Best-effort cache cleanup — never block remove on cache errors. - _ = ClearCache(domain, resolvedBackend, name) - return nil -} - -// removeLegacyProfileBindings drops the environment-agnostic Workspace and -// Project references from the same Config value that Remove saves with the -// Profile deletion. Workspace registration metadata (name/root) is retained. -func removeLegacyProfileBindings(cfg *Config, domain Domain, backend, name string) { - if cfg == nil || len(cfg.Workspaces) == 0 { - return - } - sectionKey := SectionKey(domain, backend) - for workspaceID, workspace := range cfg.Workspaces { - if workspace.Profiles[sectionKey] == name { - delete(workspace.Profiles, sectionKey) - } - if len(workspace.Profiles) == 0 { - workspace.Profiles = nil - } - for projectName, project := range workspace.Projects { - if project.Profiles[sectionKey] == name { - delete(project.Profiles, sectionKey) - } - if project.IsEmpty() { - delete(workspace.Projects, projectName) - } else { - workspace.Projects[projectName] = project - } - } - if len(workspace.Projects) == 0 { - workspace.Projects = nil - } - if workspace.IsEmpty() { - delete(cfg.Workspaces, workspaceID) - } else { - cfg.Workspaces[workspaceID] = workspace - } - } - if len(cfg.Workspaces) == 0 { - cfg.Workspaces = nil - } -} - -// SetDefault sets the default profile for a (domain, backend). When backend -// is empty, the function searches across every backend in the domain -// and disambiguates the same way Remove does. Returns PROFILE_NOT_FOUND -// when name doesn't exist in the resolved section. -func SetDefault(domain Domain, backend, name string) error { - if err := ValidateName(name); err != nil { - return err - } - cfg, _, err := Load() - if err != nil { - return err - } - resolvedBackend, err := resolveBackendFromName(cfg, domain, backend, name) - if err != nil { - return err - } - policy, ok := schemaPolicy(domain, resolvedBackend) - if !ok { - return invalidProfilePair(domain, resolvedBackend) - } - policy.setDefault(cfg, name) - return Save(cfg) -} - -// BindWorkspaceProfile records a machine-local profile choice for a -// workspace, optionally scoped to a single project. It does not mutate -// the section's default pointer; this is the per-workspace equivalent of -// `SetDefault` and is intentionally kept out of one.manifest.json. -func BindWorkspaceProfile(workspaceID, workspaceName, root, projectName string, domain Domain, backend, name string) error { - workspaceID = strings.TrimSpace(workspaceID) - if workspaceID == "" { - return cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - "workspace id 不能为空;请确认 one.manifest.json#workspace.id 已设置。") - } - if err := ValidateName(name); err != nil { - return err - } - if err := validateBackend(domain, backend); err != nil { - return err - } - cfg, _, err := Load() - if err != nil { - return err - } - if exists, names := profileExists(cfg, domain, backend, name); !exists { - return profileNotFound(SectionKey(domain, backend), name, "workspace", names) - } - if cfg.Workspaces == nil { - cfg.Workspaces = map[string]WorkspaceConfig{} - } - ws := cfg.Workspaces[workspaceID] - if workspaceName = strings.TrimSpace(workspaceName); workspaceName != "" { - ws.Name = workspaceName - } - if root = strings.TrimSpace(root); root != "" { - ws.Root = root - } - key := SectionKey(domain, backend) - if projectName = strings.TrimSpace(projectName); projectName != "" { - if ws.Projects == nil { - ws.Projects = map[string]WorkspaceProjectConfig{} - } - project := ws.Projects[projectName] - if project.Profiles == nil { - project.Profiles = map[string]string{} - } - project.Profiles[key] = name - ws.Projects[projectName] = project - } else { - if ws.Profiles == nil { - ws.Profiles = map[string]string{} - } - ws.Profiles[key] = name - } - cfg.Workspaces[workspaceID] = ws - return Save(cfg) -} - -// UnbindWorkspaceProfile removes one machine-local workspace or project -// profile choice. It is idempotent and only edits Config.Workspaces; profile -// definitions and credentials remain untouched. Empty projectName removes the -// workspace-level choice, while a non-empty projectName removes only that -// project's override so resolution falls back to workspace/default precedence. -func UnbindWorkspaceProfile( - workspaceID, projectName string, - domain Domain, - backend string, -) error { - workspaceID = strings.TrimSpace(workspaceID) - if workspaceID == "" { - return cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - "workspace id 不能为空;请确认 one.manifest.json#workspace.id 已设置。") - } - if err := validateBackend(domain, backend); err != nil { - return err - } - cfg, _, err := Load() - if err != nil { - return err - } - if cfg.Workspaces == nil { - return nil - } - ws, ok := cfg.Workspaces[workspaceID] - if !ok { - return nil - } - key := SectionKey(domain, backend) - changed := false - if projectName = strings.TrimSpace(projectName); projectName != "" { - project, exists := ws.Projects[projectName] - if exists { - if _, exists := project.Profiles[key]; exists { - delete(project.Profiles, key) - changed = true - } - if project.IsEmpty() { - delete(ws.Projects, projectName) - } else { - ws.Projects[projectName] = project - } - } - } else if _, exists := ws.Profiles[key]; exists { - delete(ws.Profiles, key) - changed = true - } - if !changed { - return nil - } - if ws.IsEmpty() { - delete(cfg.Workspaces, workspaceID) - } else { - cfg.Workspaces[workspaceID] = ws - } - return Save(cfg) -} - -// resolveBackendFromName fills in `backend` when the caller didn't -// know which backend a profile name lives under. The new top-level -// `one configure / ...` tree always passes an explicit -// backend; this helper survives mainly for resolver callers that -// search by name across a domain. When backend is supplied, the -// function still validates that the profile exists in that section. -// -// When backend is empty, the function searches every backend in the -// domain. A unique match is returned. Multiple matches return -// PROFILE_BACKEND_INVALID listing the candidate backends; no match -// returns PROFILE_NOT_FOUND with the union of available names. -func resolveBackendFromName(cfg *Config, domain Domain, backend, name string) (string, error) { - if err := ValidateName(name); err != nil { - return "", err - } - if backend != "" { - if err := validateBackend(domain, backend); err != nil { - return "", err - } - exists, names := profileExists(cfg, domain, backend, name) - if !exists { - return "", profileNotFound(SectionKey(domain, backend), name, "lookup", names) - } - return backend, nil - } - - matches := []string{} - allNames := []string{} - for _, b := range BackendsForDomain(domain) { - exists, names := profileExists(cfg, domain, b, name) - allNames = append(allNames, names...) - if exists { - matches = append(matches, b) - } - } - switch len(matches) { - case 1: - return matches[0], nil - case 0: - return "", cliErrors.New(cliErrors.PROFILE_NOT_FOUND, - fmt.Sprintf("%s 域没有名为 %q 的 profile。已配置:%v", - domain, name, allNames)). - WithContext(map[string]any{ - "domain": string(domain), - "requested": name, - "available_profiles": allNames, - }) - default: - return "", cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("%q 在 %s 域多个 backend 下都存在 (%v);用 `one configure %s/ ...` 形式指定具体 backend", - name, domain, matches, domain)). - WithContext(map[string]any{ - "domain": string(domain), - "requested": name, - "matching_backends": matches, - }) - } -} - -// profileExists returns whether `name` is configured under (domain, -// backend) and the list of currently-configured profile names in that -// section (for diagnostic error messages). -func profileExists(cfg *Config, domain Domain, backend, name string) (bool, []string) { - policy, ok := schemaPolicy(domain, backend) - if !ok { - return false, nil - } - _, exists := policy.lookup(cfg, name) - return exists, policy.names(cfg) -} - -// writeProfile destructures a Profile into the typed sub-profile that -// belongs in the section keyed by (domain, backend), then writes it -// + (optionally) sets the section's default pointer. -func writeProfile(cfg *Config, domain Domain, backend, name string, profile Profile, setDefault bool) error { - policy, ok := schemaPolicy(domain, backend) - if !ok { - return invalidProfilePair(domain, backend) - } - return policy.write(cfg, name, profile, setDefault) -} - -// validateBackend checks that backend is a known backend for the -// declared domain. Catches typos early ("infisicaal") and -// cross-domain mistakes ("docker" attached to an env profile). -func validateBackend(domain Domain, backend string) error { - if backend == "" { - return cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - "profile 缺 backend 字段。") - } - if _, ok := schemaPolicy(domain, backend); ok { - return nil - } - known := BackendsForDomain(domain) - return cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("backend %q 不属于 %s 域(合法值:%v)。", - backend, domain, known)). - WithContext(map[string]any{ - "backend": backend, - "profile_domain": string(domain), - }) -} diff --git a/packages/cli/internal/core/profile/mutate_test.go b/packages/cli/internal/core/profile/mutate_test.go deleted file mode 100644 index 32dc1878..00000000 --- a/packages/cli/internal/core/profile/mutate_test.go +++ /dev/null @@ -1,640 +0,0 @@ -package profile - -// Locks the Upsert vs Add semantic split: Upsert silently overwrites -// while Add errors PROFILE_ALREADY_EXISTS. Setup commands rely on -// Upsert; the legacy ` profile add` CRUD surface keeps -// strict-add. Also covers the per-(domain, backend) section split -// plus the AWS-style two-file split: same name across different -// backends can coexist; secrets land in credentials.json, never in -// config.json. - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func withIsolatedConfig(t *testing.T) string { - t.Helper() - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - t.Setenv("HOME", tmp) - return tmp -} - -func cfgPaths(tmp string) (cfg, creds string) { - return filepath.Join(tmp, "one", "config.json"), - filepath.Join(tmp, "one", "credentials.json") -} - -// First Upsert creates a fresh entry and reports updated=false. The -// "first profile becomes default" auto-default rule fires regardless of -// the setDefault flag. -func TestUpsert_FreshEntry(t *testing.T) { - withIsolatedConfig(t) - updated, err := Upsert(DomainContainer, "docker", "acr-prod", Profile{ - Backend: "docker", - Container: &ContainerProfile{ - Registry: "registry.example.com", - Credentials: &ContainerCredentials{ - Username: "u", Password: "p", - }, - }, - }, false) - if err != nil { - t.Fatalf("upsert: %v", err) - } - if updated { - t.Errorf("first add: updated=true, want false") - } - cfg, _, err := Load() - if err != nil { - t.Fatalf("load: %v", err) - } - if cfg.ContainerDocker.Default != "acr-prod" { - t.Errorf("auto-default rule failed: default=%q", cfg.ContainerDocker.Default) - } - if cfg.ContainerDocker.Profiles["acr-prod"].Registry != "registry.example.com" { - t.Errorf("registry not persisted: %#v", cfg.ContainerDocker.Profiles["acr-prod"]) - } - // Credentials must come back via Load → mergeCredentials. - if cred := cfg.ContainerDocker.Profiles["acr-prod"].Credentials; cred == nil || - cred.Username != "u" || cred.Password != "p" { - t.Errorf("credentials not merged from credentials.json: %+v", cred) - } -} - -// Same name twice updates in place, returns updated=true, leaves the -// default pointer where it was. -func TestUpsert_OverwriteExisting(t *testing.T) { - withIsolatedConfig(t) - first := Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ - ClientID: "cid-1", ClientSecret: "cs-1", - }, - }, - } - if _, err := Upsert(DomainEnv, "infisical", "work", first, false); err != nil { - t.Fatalf("first: %v", err) - } - second := first - second.Infisical = &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ - ClientID: "cid-1", ClientSecret: "cs-2-rotated", - }, - } - updated, err := Upsert(DomainEnv, "infisical", "work", second, false) - if err != nil { - t.Fatalf("second: %v", err) - } - if !updated { - t.Errorf("second upsert: updated=false, want true") - } - cfg, _, _ := Load() - if got := cfg.EnvInfisical.Profiles["work"].Credentials.ClientSecret; got != "cs-2-rotated" { - t.Errorf("clientSecret not rotated: got %q", got) - } -} - -// Same name in different backends doesn't collide because sections are split -// pins each profile to its own (domain, backend) section. -func TestUpsert_NameAcrossBackendsDoesNotCollide(t *testing.T) { - withIsolatedConfig(t) - if _, err := Upsert(DomainEnv, "infisical", "work", Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: "y"}, - }, - }, false); err != nil { - t.Fatalf("infisical: %v", err) - } - if _, err := Upsert(DomainContainer, "docker", "work", Profile{ - Backend: "docker", - Container: &ContainerProfile{ - Registry: "registry.example.com", - Credentials: &ContainerCredentials{Username: "u", Password: "p"}, - }, - }, false); err != nil { - t.Fatalf("docker: %v", err) - } - cfg, _, _ := Load() - if cfg.EnvInfisical.Profiles["work"].SiteURL != "https://app.infisical.com" { - t.Errorf("infisical work lost") - } - if cfg.ContainerDocker.Profiles["work"].Registry != "registry.example.com" { - t.Errorf("docker work lost") - } -} - -// SaveAt + LoadAt round-trip Container profile shape, including the -// AWS-style file split. -func TestContainerProfile_Roundtrip(t *testing.T) { - tmp := withIsolatedConfig(t) - cfgPath, credPath := cfgPaths(tmp) - cfg := &Config{Version: SchemaVersion} - cfg.ContainerDocker.Profiles = map[string]ContainerProfile{ - "acr-prod": { - Registry: "registry.example.com", - Credentials: &ContainerCredentials{ - Username: "ram-ak", - Password: "ram-secret", - }, - }, - } - cfg.ContainerDocker.Default = "acr-prod" - if err := SaveAt(cfg, cfgPath, credPath); err != nil { - t.Fatalf("save: %v", err) - } - got, _, err := LoadAt(cfgPath, credPath) - if err != nil { - t.Fatalf("load: %v", err) - } - cp := got.ContainerDocker.Profiles["acr-prod"] - if cp.Registry != "registry.example.com" { - t.Errorf("fields lost: %#v", cp) - } - if cp.Credentials == nil || cp.Credentials.Username != "ram-ak" || cp.Credentials.Password != "ram-secret" { - t.Errorf("credentials lost: %#v", cp.Credentials) - } - for _, p := range []string{cfgPath, credPath} { - st, err := os.Stat(p) - if err != nil { - t.Fatalf("stat %s: %v", p, err) - } - if mode := st.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { - t.Errorf("file mode %s: got %o want 0600", p, mode) - } - } - // config.json must NOT contain the password. - cfgRaw, _ := os.ReadFile(cfgPath) - if strings.Contains(string(cfgRaw), "ram-secret") { - t.Errorf("password leaked into config.json:\n%s", cfgRaw) - } - if strings.Contains(string(cfgRaw), "\"credentials\"") { - t.Errorf("credentials key present in config.json (should be empty):\n%s", cfgRaw) - } - // credentials.json must contain the password. - credRaw, _ := os.ReadFile(credPath) - if !strings.Contains(string(credRaw), "ram-secret") { - t.Errorf("password missing from credentials.json:\n%s", credRaw) - } -} - -// Both files coexist independently: Save writes config.json + -// credentials.json. Load tolerates either side missing — all missing -// returns an empty Config / CredentialsFile pair with the current -// schema version. -func TestLoad_MissingFiles(t *testing.T) { - tmp := withIsolatedConfig(t) - cfgPath, credPath := cfgPaths(tmp) - - cfg, creds, err := LoadAt(cfgPath, credPath) - if err != nil { - t.Fatalf("fresh: %v", err) - } - if cfg.Version != SchemaVersion || creds.Version != SchemaVersion { - t.Errorf("fresh load did not init Version: cfg=%d creds=%d", cfg.Version, creds.Version) - } -} - -// Saving with no profiles still emits {"version":} in both -// files, not raw `null` or empty objects. Keeps Load happy on -// round-trip. Compares against SchemaVersion so the test follows the -// schema bump without hand edits. -func TestSave_EmptyConfigShape(t *testing.T) { - tmp := withIsolatedConfig(t) - cfgPath, credPath := cfgPaths(tmp) - if err := SaveAt(&Config{Version: SchemaVersion}, cfgPath, credPath); err != nil { - t.Fatalf("save: %v", err) - } - wantVersion := fmt.Sprintf("%d", SchemaVersion) - for _, p := range []string{cfgPath, credPath} { - raw, _ := os.ReadFile(p) - var probe map[string]json.RawMessage - if err := json.Unmarshal(raw, &probe); err != nil { - t.Fatalf("parse %s: %v", p, err) - } - if string(probe["version"]) != wantVersion { - t.Errorf("%s version key missing or wrong: %s", p, raw) - } - } -} - -// Legacy docs used an `active` pointer before the current `default` -// pointer. Loading one must surface PROFILE_VERSION_UNSUPPORTED rather -// than risking default loss on the next save. -func TestLoad_RejectsLegacyActiveSchema(t *testing.T) { - tmp := withIsolatedConfig(t) - cfgPath, credPath := cfgPaths(tmp) - if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil { - t.Fatalf("mkdir: %v", err) - } - legacyDoc := `{ - "version": 0, - "env/infisical": { - "active": "work", - "profiles": { - "work": {"siteUrl": "https://app.infisical.com"} - } - } - }` - if err := os.WriteFile(cfgPath, []byte(legacyDoc), 0o600); err != nil { - t.Fatalf("write legacy cfg: %v", err) - } - if err := os.WriteFile(credPath, []byte(`{"version":0}`), 0o600); err != nil { - t.Fatalf("write legacy creds: %v", err) - } - _, _, err := LoadAt(cfgPath, credPath) - if err == nil { - t.Fatal("expected PROFILE_VERSION_UNSUPPORTED for legacy cfg") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "PROFILE_VERSION_UNSUPPORTED" { - t.Fatalf("error = %v, want PROFILE_VERSION_UNSUPPORTED", err) - } -} - -// A document with a version OLDER than MinSupportedVersion must surface PROFILE_VERSION_UNSUPPORTED -// rather than be silently parsed. -func TestLoad_RejectsBelowMinSupportedVersion(t *testing.T) { - tmp := withIsolatedConfig(t) - cfgPath, credPath := cfgPaths(tmp) - if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(cfgPath, []byte(`{"version":0}`), 0o600); err != nil { - t.Fatalf("write old cfg: %v", err) - } - _, _, err := LoadAt(cfgPath, credPath) - if err == nil { - t.Fatal("expected PROFILE_VERSION_UNSUPPORTED for old cfg") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "PROFILE_VERSION_UNSUPPORTED" { - t.Fatalf("error = %v, want PROFILE_VERSION_UNSUPPORTED", err) - } -} - -// A document with a version NEWER than this binary's SchemaVersion -// must also be rejected — running an older binary against a config -// the user upgraded to a newer schema is a real footgun (silent data -// loss on save), so we surface it loudly. -func TestLoad_RejectsAboveSchemaVersion(t *testing.T) { - tmp := withIsolatedConfig(t) - cfgPath, credPath := cfgPaths(tmp) - if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil { - t.Fatalf("mkdir: %v", err) - } - future := fmt.Sprintf(`{"version":%d}`, SchemaVersion+1) - if err := os.WriteFile(cfgPath, []byte(future), 0o600); err != nil { - t.Fatalf("write future cfg: %v", err) - } - _, _, err := LoadAt(cfgPath, credPath) - if err == nil { - t.Fatal("expected PROFILE_VERSION_UNSUPPORTED for future schema") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || cliErr.ErrorCode() != "PROFILE_VERSION_UNSUPPORTED" { - t.Fatalf("error = %v, want PROFILE_VERSION_UNSUPPORTED", err) - } -} - -// Add (strict mode) refuses overwrite within the same section. -func TestAdd_RejectsDuplicate(t *testing.T) { - withIsolatedConfig(t) - mk := func() Profile { - return Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ - ClientID: "x", ClientSecret: "y", - }, - }, - } - } - if err := Add(DomainEnv, "infisical", "work", mk(), false); err != nil { - t.Fatalf("first add: %v", err) - } - err := Add(DomainEnv, "infisical", "work", mk(), false) - if err == nil { - t.Fatalf("expected PROFILE_ALREADY_EXISTS") - } - if !strings.Contains(err.Error(), "已存在") { - t.Errorf("unexpected error: %v", err) - } -} - -// Remove deletes from both files and clears the cache file. -func TestRemove_ClearsCache(t *testing.T) { - withIsolatedConfig(t) - if _, err := Upsert(DomainEnv, "infisical", "work", Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: "y"}, - }, - }, false); err != nil { - t.Fatalf("seed: %v", err) - } - // Plant a fake cache entry. - if err := WriteCache(DomainEnv, "infisical", "work", &CacheEntry{ - Token: "stale-token", - ExpiresAt: time.Now().Add(time.Hour), - }); err != nil { - t.Fatalf("write cache: %v", err) - } - if err := Remove(DomainEnv, "infisical", "work"); err != nil { - t.Fatalf("remove: %v", err) - } - cachePath, _ := CachePath(DomainEnv, "infisical", "work") - if _, err := os.Stat(cachePath); !os.IsNotExist(err) { - t.Errorf("cache file should be gone after remove, stat err=%v", err) - } -} - -func TestRemove_RejectsEnvironmentBindingsThenCleansLegacyAfterUnbind(t *testing.T) { - withIsolatedConfig(t) - firstRoot := t.TempDir() - secondRoot := t.TempDir() - for _, root := range []string{firstRoot, secondRoot} { - if err := os.WriteFile(filepath.Join(root, "sentinel.txt"), []byte("repository\n"), 0o644); err != nil { - t.Fatal(err) - } - } - seedInfisicalProfiles(t, "removed", "kept") - if _, err := Upsert(DomainDeploy, "vercel", "removed", Profile{ - Backend: "vercel", - Vercel: &VercelProfile{Credentials: &VercelCredentials{ - APIToken: "same-name-other-section", - }}, - }, false); err != nil { - t.Fatal(err) - } - if err := SetDefault(DomainEnv, "infisical", "kept"); err != nil { - t.Fatal(err) - } - - for _, binding := range []struct { - workspaceID string - root string - project string - environment string - name string - }{ - {"first", firstRoot, "", "dev", "removed"}, - {"first", firstRoot, "web", "preview", "removed"}, - {"first", firstRoot, "api", "preview", "kept"}, - {"second", secondRoot, "web", "prod", "removed"}, - } { - if err := BindEnvironmentProfile( - binding.workspaceID, binding.workspaceID, binding.root, binding.project, - binding.environment, DomainEnv, "infisical", binding.name, - ); err != nil { - t.Fatal(err) - } - } - if err := BindEnvironmentProfile( - "first", "first", firstRoot, "web", "preview", - DomainDeploy, "vercel", "removed", - ); err != nil { - t.Fatal(err) - } - for _, binding := range []struct { - workspaceID string - root string - project string - name string - }{ - {"first", firstRoot, "", "removed"}, - {"first", firstRoot, "web", "removed"}, - {"first", firstRoot, "api", "kept"}, - {"second", secondRoot, "web", "removed"}, - } { - if err := BindWorkspaceProfile( - binding.workspaceID, binding.workspaceID, binding.root, binding.project, - DomainEnv, "infisical", binding.name, - ); err != nil { - t.Fatal(err) - } - } - if err := BindWorkspaceProfile( - "first", "first", firstRoot, "web", DomainDeploy, "vercel", "removed", - ); err != nil { - t.Fatal(err) - } - if err := WriteCache(DomainEnv, "infisical", "removed", &CacheEntry{ - Token: "still-valid", ExpiresAt: time.Now().Add(time.Hour), - }); err != nil { - t.Fatal(err) - } - - configPath, err := ConfigPath() - if err != nil { - t.Fatal(err) - } - credentialsPath, err := CredentialsPath() - if err != nil { - t.Fatal(err) - } - bindingsPath, err := BindingsPath() - if err != nil { - t.Fatal(err) - } - cachePath, err := CachePath(DomainEnv, "infisical", "removed") - if err != nil { - t.Fatal(err) - } - localPaths := []string{configPath, credentialsPath, bindingsPath, cachePath} - beforeRejectedRemove := make(map[string][]byte, len(localPaths)) - for _, path := range localPaths { - beforeRejectedRemove[path], err = os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - } - - err = Remove(DomainEnv, "infisical", "removed") - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != "PROFILE_IN_USE" { - t.Fatalf("remove error = %v, want PROFILE_IN_USE", err) - } - for _, path := range localPaths { - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if !bytes.Equal(after, beforeRejectedRemove[path]) { - t.Fatalf("rejected remove changed %s", path) - } - } - - targetBindings := []struct { - root, project, environment string - }{ - {firstRoot, "", "dev"}, - {firstRoot, "web", "preview"}, - {secondRoot, "web", "prod"}, - } - for _, binding := range targetBindings { - got, err := EnvironmentProfileBinding( - binding.root, binding.project, binding.environment, DomainEnv, "infisical", - ) - if err != nil { - t.Fatal(err) - } - if got != "removed" { - t.Fatalf("rejected remove lost binding: %#v = %q", binding, got) - } - if err := UnbindEnvironmentProfile( - binding.root, binding.project, binding.environment, DomainEnv, "infisical", - ); err != nil { - t.Fatal(err) - } - } - bindingsAfterUnbind, err := os.ReadFile(bindingsPath) - if err != nil { - t.Fatal(err) - } - if err := Remove(DomainEnv, "infisical", "removed"); err != nil { - t.Fatal(err) - } - bindingsAfterRemove, err := os.ReadFile(bindingsPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(bindingsAfterRemove, bindingsAfterUnbind) { - t.Fatal("Profile removal changed the independent environment binding store") - } - for _, binding := range targetBindings { - got, err := EnvironmentProfileBinding( - binding.root, binding.project, binding.environment, DomainEnv, "infisical", - ) - if err != nil { - t.Fatal(err) - } - if got != "" { - t.Fatalf("explicit unbind did not persist: %#v = %q", binding, got) - } - } - kept, err := EnvironmentProfileBinding( - firstRoot, "api", "preview", DomainEnv, "infisical", - ) - if err != nil || kept != "kept" { - t.Fatalf("unrelated environment binding = %q, err = %v", kept, err) - } - otherSection, err := EnvironmentProfileBinding( - firstRoot, "web", "preview", DomainDeploy, "vercel", - ) - if err != nil || otherSection != "removed" { - t.Fatalf("same name in another typed section = %q, err = %v", otherSection, err) - } - - cfg, _, err := Load() - if err != nil { - t.Fatal(err) - } - if _, exists := cfg.EnvInfisical.Profiles["removed"]; exists { - t.Fatal("removed Profile definition remains") - } - if _, exists := cfg.DeployVercel.Profiles["removed"]; !exists { - t.Fatal("same Profile name in another typed section was removed") - } - sectionKey := SectionKey(DomainEnv, "infisical") - for workspaceID, workspace := range cfg.Workspaces { - if workspace.Profiles[sectionKey] == "removed" { - t.Fatalf("legacy Workspace binding remains for %s", workspaceID) - } - for projectName, project := range workspace.Projects { - if project.Profiles[sectionKey] == "removed" { - t.Fatalf("legacy Project binding remains for %s/%s", workspaceID, projectName) - } - } - } - if cfg.Workspaces["first"].Projects["api"].Profiles[sectionKey] != "kept" { - t.Fatalf("unrelated legacy binding was removed: %#v", cfg.Workspaces["first"]) - } - if cfg.Workspaces["first"].Projects["web"].Profiles[SectionKey(DomainDeploy, "vercel")] != "removed" { - t.Fatalf("same-name legacy binding in another section was removed: %#v", cfg.Workspaces["first"]) - } - if cfg.Workspaces["second"].Root != secondRoot { - t.Fatalf("Workspace registration metadata was removed: %#v", cfg.Workspaces["second"]) - } - if _, err := os.Stat(cachePath); !os.IsNotExist(err) { - t.Fatalf("cache file should be removed after successful delete: %v", err) - } - for _, root := range []string{firstRoot, secondRoot} { - raw, err := os.ReadFile(filepath.Join(root, "sentinel.txt")) - if err != nil || string(raw) != "repository\n" { - t.Fatalf("repository changed at %s: raw=%q err=%v", root, raw, err) - } - } -} - -func TestRemove_BindingReadFailureLeavesAllProfileBytesUnchanged(t *testing.T) { - withIsolatedConfig(t) - root := t.TempDir() - seedInfisicalProfiles(t, "work") - if err := BindWorkspaceProfile( - "workspace-id", "demo", root, "web", DomainEnv, "infisical", "work", - ); err != nil { - t.Fatal(err) - } - if err := WriteCache(DomainEnv, "infisical", "work", &CacheEntry{ - Token: "cached", ExpiresAt: time.Now().Add(time.Hour), - }); err != nil { - t.Fatal(err) - } - - configPath, err := ConfigPath() - if err != nil { - t.Fatal(err) - } - credentialsPath, err := CredentialsPath() - if err != nil { - t.Fatal(err) - } - bindingsPath, err := BindingsPath() - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(bindingsPath, []byte(`{"version":1,"workspaces":`), 0o600); err != nil { - t.Fatal(err) - } - cachePath, err := CachePath(DomainEnv, "infisical", "work") - if err != nil { - t.Fatal(err) - } - paths := []string{configPath, credentialsPath, bindingsPath, cachePath} - before := make(map[string][]byte, len(paths)) - for _, path := range paths { - before[path], err = os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - } - - err = Remove(DomainEnv, "infisical", "work") - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != "PROFILE_FILE_INVALID" { - t.Fatalf("remove error = %v, want PROFILE_FILE_INVALID", err) - } - for _, path := range paths { - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if !bytes.Equal(after, before[path]) { - t.Fatalf("failed remove changed %s", path) - } - } -} diff --git a/packages/cli/internal/core/profile/profile_name.go b/packages/cli/internal/core/profile/profile_name.go deleted file mode 100644 index 4a05158a..00000000 --- a/packages/cli/internal/core/profile/profile_name.go +++ /dev/null @@ -1,38 +0,0 @@ -package profile - -import ( - "fmt" - "regexp" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -const maxProfileNameLength = 128 - -var profileNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) - -// ValidateName enforces the single profile-name contract used by persistent -// profile definitions, workspace bindings, and the token cache. Keeping the -// value to one portable path segment prevents a Dashboard-supplied name from -// escaping ~/.config/one/cache when it is later used as a cache filename. -func ValidateName(name string) error { - if name == "" || name != strings.TrimSpace(name) || len(name) > maxProfileNameLength || - !profileNameRE.MatchString(name) { - return cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf( - "profile 名 %q 不合法;必须匹配 %s,且长度不超过 %d。", - name, - profileNameRE.String(), - maxProfileNameLength, - ), - ).WithContext(map[string]any{ - "field": "profile name", - "value": name, - "pattern": profileNameRE.String(), - "max": maxProfileNameLength, - }) - } - return nil -} diff --git a/packages/cli/internal/core/profile/profile_name_test.go b/packages/cli/internal/core/profile/profile_name_test.go deleted file mode 100644 index 66a9c64d..00000000 --- a/packages/cli/internal/core/profile/profile_name_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package profile - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestValidateNameContract(t *testing.T) { - for _, name := range []string{ - "work", - "acr-prod", - "personal_2", - "A", - "a" + strings.Repeat("b", maxProfileNameLength-1), - } { - if err := ValidateName(name); err != nil { - t.Errorf("ValidateName(%q): %v", name, err) - } - } - - for _, name := range []string{ - "", - " work", - "work ", - "work\n", - "../work", - "..", - "team/work", - `team\work`, - ".hidden", - "prod.profile", - "-work", - "_work", - "测试", - "a" + strings.Repeat("b", maxProfileNameLength), - } { - err := ValidateName(name) - if err == nil { - t.Errorf("ValidateName(%q) unexpectedly succeeded", name) - continue - } - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != "PROFILE_BACKEND_INVALID" { - t.Errorf("ValidateName(%q) code = %v, want PROFILE_BACKEND_INVALID", name, err) - } - } -} - -func TestProfileMutationBoundariesRejectUnsafeNameBeforeWriting(t *testing.T) { - tmp := withIsolatedConfig(t) - unsafeName := "../../../../outside" - value := Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ - ClientID: "client", ClientSecret: "secret", - }, - }, - } - - operations := map[string]func() error{ - "add": func() error { - return Add(DomainEnv, "infisical", unsafeName, value, false) - }, - "upsert": func() error { - _, err := Upsert(DomainEnv, "infisical", unsafeName, value, false) - return err - }, - "remove": func() error { - return Remove(DomainEnv, "infisical", unsafeName) - }, - "set default": func() error { - return SetDefault(DomainEnv, "infisical", unsafeName) - }, - "bind workspace": func() error { - return BindWorkspaceProfile( - "workspace-id", "workspace", tmp, "api", - DomainEnv, "infisical", unsafeName, - ) - }, - } - - for name, operation := range operations { - t.Run(name, func(t *testing.T) { - if err := operation(); err == nil { - t.Fatal("unsafe profile name unexpectedly succeeded") - } - }) - } - - configPath, err := ConfigPath() - if err != nil { - t.Fatalf("ConfigPath: %v", err) - } - credentialsPath, err := CredentialsPath() - if err != nil { - t.Fatalf("CredentialsPath: %v", err) - } - for _, path := range []string{configPath, credentialsPath} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("unsafe mutation created %s; stat error = %v", path, err) - } - } -} - -func TestCacheBoundariesRejectTraversalWithoutTouchingOutsideFile(t *testing.T) { - tmp := withIsolatedConfig(t) - outsidePath := filepath.Join(tmp, "sentinel.json") - want := []byte("do-not-touch") - if err := os.WriteFile(outsidePath, want, 0o600); err != nil { - t.Fatalf("write sentinel: %v", err) - } - - // From ~/.config/one/cache/env/infisical, four parent components would - // resolve to XDG_CONFIG_HOME and reach sentinel.json without validation. - unsafeName := "../../../../sentinel" - if path, err := CachePath(DomainEnv, "infisical", unsafeName); err == nil { - t.Fatalf("CachePath returned unsafe path %q", path) - } - if entry, err := ReadCache(DomainEnv, "infisical", unsafeName); err == nil || entry != nil { - t.Fatalf("ReadCache = (%+v, %v), want validation error", entry, err) - } - if err := WriteCache(DomainEnv, "infisical", unsafeName, &CacheEntry{ - Token: "overwrite", - ExpiresAt: time.Now().Add(time.Hour), - }); err == nil { - t.Fatal("WriteCache accepted traversal name") - } - if err := ClearCache(DomainEnv, "infisical", unsafeName); err == nil { - t.Fatal("ClearCache accepted traversal name") - } - - got, err := os.ReadFile(outsidePath) - if err != nil { - t.Fatalf("sentinel was removed: %v", err) - } - if string(got) != string(want) { - t.Fatalf("sentinel changed: got %q, want %q", got, want) - } -} diff --git a/packages/cli/internal/core/profile/resolver.go b/packages/cli/internal/core/profile/resolver.go deleted file mode 100644 index fd6d94cb..00000000 --- a/packages/cli/internal/core/profile/resolver.go +++ /dev/null @@ -1,208 +0,0 @@ -package profile - -// resolver.go implements the per-call profile lookup chain. Every -// `one env ` / `one deploy ` / `one container ` -// invocation runs Resolve to pick which profile applies, then hands -// the resolved Profile to the backend. -// -// The config schema stores each (domain, backend) in its own section -// with its own default pointer in config.json; secrets live in -// credentials.json. Load merges both so the in-memory Profile shape -// already has Credentials populated for file-source profiles — -// consumers continue to read `resolved.Profile.X.Credentials.Y` -// directly. -// -// Lookup precedence (first non-empty wins): -// -// 1. --profile flag (one-shot, doesn't touch default) -// 2. profile-bindings.json#[canonicalRoot][environment].projects[projectName] -// 3. profile-bindings.json#[canonicalRoot][environment].profiles -// 4. config.json#workspaces[workspaceID].projects[projectName].profiles[domain/backend] -// 5. config.json#workspaces[workspaceID].profiles[domain/backend] -// 6. ~/.config/one/config.json#/.default (machine default) -// 7. (no profile) → PROFILE_NONE_CONFIGURED if the backend needs one. -// -// CredentialSource handling: only "file" / "" is wired up. Any other -// value surfaces PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED — an explicit -// "feature reserved" error rather than silent fallback to file. - -import ( - "fmt" - "strings" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -// ResolveInput collects the inputs the resolver needs from various -// call sites without coupling profile/ to internal/bootstrap/cli or -// internal/core/workspace. Cobra fills FlagOverride; manifest read fills -// WorkspaceID / ProjectName; the rest read by the resolver itself. -// -// Backend is required: each (domain, backend) is a separate section, -// so the resolver always needs to know which backend's default -// pointer + profiles to walk. Callers know the backend at call site -// (envcmd → "infisical", containercmd → "docker", deploycmd → the -// subproject's declared backend). -type ResolveInput struct { - Domain Domain - Backend string - FlagOverride string // value of --profile flag, "" if unset - WorkspaceID string // manifest.workspace.id, "" if unavailable - WorkspaceRoot string // workspace root; paired with Environment for local environment bindings - ProjectName string // manifest.projects[].name, "" for workspace scope - Environment string // safe environment id (for example dev / preview / prod / staging) - SkipDefault bool // when true, only flag/workspace bindings are considered -} - -// Resolved is the answer Resolve hands back. Name is the picked -// profile's id (useful for logging / output envelopes); Profile is -// the discriminated-union shape with only the matching backend field -// populated; Source describes which step in the precedence chain -// matched (for diagnostic output); CredSource records the resolved -// credentialSource ("file" / "env" / ...) so callers can render it. -type Resolved struct { - Name string - Profile Profile - Source string // "flag" / "workspace-project-environment" / "workspace-environment" / legacy / "default" - CredSource string // "file" / "env" / "command:..." / "keyring" -} - -// Resolve walks the precedence chain. Returns PROFILE_NONE_CONFIGURED -// when nothing matches; PROFILE_NOT_FOUND when a name was specified -// (flag / workspace binding / default) but no profile exists by that name; -// PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED when the resolved profile -// names a credentialSource this build cannot honour. -func Resolve(in ResolveInput) (*Resolved, error) { - if in.Backend == "" { - return nil, cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - "resolve: backend 不能为空") - } - cfg, _, err := Load() - if err != nil { - return nil, err - } - - policy, ok := schemaPolicy(in.Domain, in.Backend) - if !ok { - return nil, invalidProfilePair(in.Domain, in.Backend) - } - defaultName := policy.defaultName(cfg) - names := policy.names(cfg) - sectionKey := SectionKey(in.Domain, in.Backend) - - finalize := func(name string, source string) (*Resolved, error) { - p, ok := policy.lookup(cfg, name) - if !ok { - return nil, profileNotFound(sectionKey, name, source, names) - } - credSource := policy.credentialSource(p) - if !IsFileSource(credSource) { - return nil, cliErrors.New(cliErrors.PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED, - fmt.Sprintf("profile %q 的 credentialSource = %q 当前未实现(仅支持 \"file\")", name, credSource)). - WithContext(map[string]any{ - "section": sectionKey, - "profile": name, - "credentialSource": credSource, - }) - } - return &Resolved{Name: name, Profile: p, Source: source, CredSource: SourceFile}, nil - } - - // 1. --profile flag - if name := strings.TrimSpace(in.FlagOverride); name != "" { - return finalize(name, "flag") - } - - // 2-3. Environment-aware bindings are keyed by canonical checkout root. - // WorkspaceRoot by itself is allowed for backwards-compatible callers; - // Environment opts the call into the new binding store and therefore also - // requires a root. - if in.Environment != "" { - if strings.TrimSpace(in.WorkspaceRoot) == "" { - return nil, invalidBindingValue("workspace root", in.WorkspaceRoot) - } - projectBinding, workspaceBinding, err := environmentBindingNamesAt( - in.WorkspaceRoot, in.Environment, in.ProjectName, sectionKey, - ) - if err != nil { - return nil, err - } - if projectBinding != "" { - return finalize(projectBinding, "workspace-project-environment") - } - if workspaceBinding != "" { - return finalize(workspaceBinding, "workspace-environment") - } - } - - // 4. legacy per-project workspace binding - if name := workspaceProjectBinding(cfg, in.WorkspaceID, in.ProjectName, sectionKey); name != "" { - return finalize(name, "workspace-project") - } - - // 5. legacy workspace binding - if name := workspaceBinding(cfg, in.WorkspaceID, sectionKey); name != "" { - return finalize(name, "workspace") - } - - // 6. machine default for this (domain, backend) - if name := strings.TrimSpace(defaultName); name != "" && !in.SkipDefault { - return finalize(name, "default") - } - - // 7. nothing matched - return nil, cliErrors.New(cliErrors.PROFILE_NONE_CONFIGURED, - fmt.Sprintf("没有配置 %s profile。先 `one configure %s/%s add ` 创建。", - sectionKey, in.Domain, in.Backend)). - WithContext(map[string]any{ - "domain": string(in.Domain), - "backend": in.Backend, - }) -} - -func workspaceProjectBinding(cfg *Config, workspaceID, projectName, sectionKey string) string { - ws := workspaceConfig(cfg, workspaceID) - if ws == nil || strings.TrimSpace(projectName) == "" { - return "" - } - project, ok := ws.Projects[strings.TrimSpace(projectName)] - if !ok { - return "" - } - return strings.TrimSpace(project.Profiles[sectionKey]) -} - -func workspaceBinding(cfg *Config, workspaceID, sectionKey string) string { - ws := workspaceConfig(cfg, workspaceID) - if ws == nil { - return "" - } - return strings.TrimSpace(ws.Profiles[sectionKey]) -} - -func workspaceConfig(cfg *Config, workspaceID string) *WorkspaceConfig { - if cfg == nil || len(cfg.Workspaces) == 0 { - return nil - } - workspaceID = strings.TrimSpace(workspaceID) - if workspaceID == "" { - return nil - } - ws, ok := cfg.Workspaces[workspaceID] - if !ok { - return nil - } - return &ws -} - -func profileNotFound(sectionKey, name, source string, available []string) error { - return cliErrors.New(cliErrors.PROFILE_NOT_FOUND, - fmt.Sprintf("没有名为 %q 的 %s profile(来源:%s)。已配置:%v", - name, sectionKey, source, available)). - WithContext(map[string]any{ - "section": sectionKey, - "requested": name, - "source": source, - "available_profiles": available, - }) -} diff --git a/packages/cli/internal/core/profile/resolver_test.go b/packages/cli/internal/core/profile/resolver_test.go deleted file mode 100644 index cb340363..00000000 --- a/packages/cli/internal/core/profile/resolver_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package profile - -import ( - "testing" -) - -// File-source profile resolves and surfaces CredSource="file". -func TestResolve_FileSource(t *testing.T) { - withIsolatedConfig(t) - if _, err := Upsert(DomainEnv, "infisical", "work", Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: "y"}, - }, - }, false); err != nil { - t.Fatalf("seed: %v", err) - } - resolved, err := Resolve(ResolveInput{Domain: DomainEnv, Backend: "infisical"}) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if resolved.Name != "work" { - t.Errorf("name: %q", resolved.Name) - } - if resolved.Source != "default" { - t.Errorf("source: %q want default", resolved.Source) - } - if resolved.CredSource != SourceFile { - t.Errorf("credSource: %q want file", resolved.CredSource) - } - if resolved.Profile.Infisical.Credentials == nil || - resolved.Profile.Infisical.Credentials.ClientSecret != "y" { - t.Errorf("credentials not populated: %+v", resolved.Profile.Infisical) - } -} - -// Empty CredentialSource is treated as "file". -func TestResolve_EmptySourceTreatedAsFile(t *testing.T) { - withIsolatedConfig(t) - if _, err := Upsert(DomainEnv, "infisical", "work", Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - // CredentialSource left blank. - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: "y"}, - }, - }, false); err != nil { - t.Fatalf("seed: %v", err) - } - resolved, err := Resolve(ResolveInput{Domain: DomainEnv, Backend: "infisical"}) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if resolved.CredSource != SourceFile { - t.Errorf("expected file fallback, got %q", resolved.CredSource) - } -} - -// Non-file credentialSource surfaces PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED. -func TestResolve_UnsupportedSource(t *testing.T) { - withIsolatedConfig(t) - if _, err := Upsert(DomainEnv, "infisical", "work", Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - CredentialSource: SourceKeyring, - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: "y"}, - }, - }, false); err != nil { - t.Fatalf("seed: %v", err) - } - _, err := Resolve(ResolveInput{Domain: DomainEnv, Backend: "infisical"}) - if err == nil { - t.Fatalf("expected unsupported error") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || - cliErr.ErrorCode() != "PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED" { - t.Errorf("expected PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED, got %T %v", err, err) - } -} - -func TestResolve_UnsupportedSourceUsesSchemaPolicyForEveryTypedBackend(t *testing.T) { - tests := []struct { - backend string - profile Profile - }{ - { - backend: "cloudflare", - profile: Profile{ - Backend: "cloudflare", - Cloudflare: &CloudflareProfile{CredentialSource: SourceKeyring}, - }, - }, - { - backend: "edgeone", - profile: Profile{ - Backend: "edgeone", - EdgeOne: &EdgeOneProfile{CredentialSource: SourceKeyring}, - }, - }, - } - - for _, test := range tests { - t.Run(test.backend, func(t *testing.T) { - withIsolatedConfig(t) - if _, err := Upsert(DomainDeploy, test.backend, "work", test.profile, false); err != nil { - t.Fatalf("seed: %v", err) - } - _, err := Resolve(ResolveInput{Domain: DomainDeploy, Backend: test.backend}) - if err == nil { - t.Fatal("expected unsupported credential source error") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || - cliErr.ErrorCode() != "PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED" { - t.Fatalf("expected PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED, got %T %v", err, err) - } - }) - } -} - -// PROFILE_NONE_CONFIGURED when no profile / flag / env / manifest provides anything. -func TestResolve_NoneConfigured(t *testing.T) { - withIsolatedConfig(t) - _, err := Resolve(ResolveInput{Domain: DomainEnv, Backend: "infisical"}) - if err == nil { - t.Fatalf("expected PROFILE_NONE_CONFIGURED") - } - if cliErr, ok := err.(interface{ ErrorCode() string }); !ok || - cliErr.ErrorCode() != "PROFILE_NONE_CONFIGURED" { - t.Errorf("expected PROFILE_NONE_CONFIGURED, got %T %v", err, err) - } -} - -// --profile flag wins over default. -func TestResolve_FlagOverridesDefault(t *testing.T) { - withIsolatedConfig(t) - for _, n := range []string{"work", "personal"} { - if _, err := Upsert(DomainEnv, "infisical", n, Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: n}, - }, - }, false); err != nil { - t.Fatalf("seed %s: %v", n, err) - } - } - // "work" was added first → default. Flag picks "personal". - resolved, err := Resolve(ResolveInput{ - Domain: DomainEnv, - Backend: "infisical", - FlagOverride: "personal", - }) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if resolved.Source != "flag" || resolved.Name != "personal" { - t.Errorf("flag did not win: source=%q name=%q", resolved.Source, resolved.Name) - } -} - -func TestResolve_WorkspaceBindingOverridesDefault(t *testing.T) { - withIsolatedConfig(t) - for _, n := range []string{"default", "workspace"} { - if _, err := Upsert(DomainEnv, "infisical", n, Profile{ - Backend: "infisical", - Infisical: &InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &InfisicalCredentials{ClientID: "x", ClientSecret: n}, - }, - }, n == "default"); err != nil { - t.Fatalf("seed %s: %v", n, err) - } - } - if err := BindWorkspaceProfile("ws-demo", "demo", "/tmp/demo", "", DomainEnv, "infisical", "workspace"); err != nil { - t.Fatalf("bind workspace: %v", err) - } - resolved, err := Resolve(ResolveInput{ - Domain: DomainEnv, - Backend: "infisical", - WorkspaceID: "ws-demo", - }) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if resolved.Source != "workspace" || resolved.Name != "workspace" { - t.Errorf("workspace binding did not win: source=%q name=%q", resolved.Source, resolved.Name) - } -} - -func TestResolve_ProjectBindingOverridesWorkspaceBinding(t *testing.T) { - withIsolatedConfig(t) - for _, n := range []string{"default", "workspace", "project"} { - if _, err := Upsert(DomainDeploy, "vercel", n, Profile{ - Backend: "vercel", - Vercel: &VercelProfile{ - Team: n, - Credentials: &VercelCredentials{APIToken: n}, - }, - }, n == "default"); err != nil { - t.Fatalf("seed %s: %v", n, err) - } - } - if err := BindWorkspaceProfile("ws-demo", "demo", "/tmp/demo", "", DomainDeploy, "vercel", "workspace"); err != nil { - t.Fatalf("bind workspace: %v", err) - } - if err := BindWorkspaceProfile("ws-demo", "demo", "/tmp/demo", "web", DomainDeploy, "vercel", "project"); err != nil { - t.Fatalf("bind project: %v", err) - } - resolved, err := Resolve(ResolveInput{ - Domain: DomainDeploy, - Backend: "vercel", - WorkspaceID: "ws-demo", - ProjectName: "web", - }) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if resolved.Source != "workspace-project" || resolved.Name != "project" { - t.Errorf("project binding did not win: source=%q name=%q", resolved.Source, resolved.Name) - } -} - -func TestUnbindWorkspaceProfileRestoresPrecedenceAndCleansProjectOverride(t *testing.T) { - withIsolatedConfig(t) - for _, name := range []string{"default", "workspace", "project"} { - if _, err := Upsert(DomainDeploy, "vercel", name, Profile{ - Backend: "vercel", - Vercel: &VercelProfile{ - Team: name, - Credentials: &VercelCredentials{APIToken: "token-" + name}, - }, - }, name == "default"); err != nil { - t.Fatalf("seed %s: %v", name, err) - } - } - if err := BindWorkspaceProfile( - "ws-demo", "demo", "/tmp/demo", "", DomainDeploy, "vercel", "workspace", - ); err != nil { - t.Fatalf("bind workspace: %v", err) - } - if err := BindWorkspaceProfile( - "ws-demo", "demo", "/tmp/demo", "web", DomainDeploy, "vercel", "project", - ); err != nil { - t.Fatalf("bind project: %v", err) - } - - if err := UnbindWorkspaceProfile("ws-demo", "web", DomainDeploy, "vercel"); err != nil { - t.Fatalf("unbind project: %v", err) - } - resolved, err := Resolve(ResolveInput{ - Domain: DomainDeploy, Backend: "vercel", WorkspaceID: "ws-demo", ProjectName: "web", - }) - if err != nil { - t.Fatal(err) - } - if resolved.Source != "workspace" || resolved.Name != "workspace" { - t.Fatalf("after project unbind = %#v, want workspace binding", resolved) - } - config, _, err := Load() - if err != nil { - t.Fatal(err) - } - if _, exists := config.Workspaces["ws-demo"].Projects["web"]; exists { - t.Fatal("empty project binding entry was not removed") - } - if got := config.DeployVercel.Profiles["project"].Credentials; got == nil || got.APIToken != "token-project" { - t.Fatalf("unbind changed profile credentials: %#v", got) - } - - if err := UnbindWorkspaceProfile("ws-demo", "", DomainDeploy, "vercel"); err != nil { - t.Fatalf("unbind workspace: %v", err) - } - resolved, err = Resolve(ResolveInput{ - Domain: DomainDeploy, Backend: "vercel", WorkspaceID: "ws-demo", ProjectName: "web", - }) - if err != nil { - t.Fatal(err) - } - if resolved.Source != "default" || resolved.Name != "default" { - t.Fatalf("after workspace unbind = %#v, want machine default", resolved) - } - // Removing the same binding twice is a no-op. - if err := UnbindWorkspaceProfile("ws-demo", "", DomainDeploy, "vercel"); err != nil { - t.Fatalf("idempotent unbind: %v", err) - } -} diff --git a/packages/cli/internal/core/profile/schema.go b/packages/cli/internal/core/profile/schema.go deleted file mode 100644 index bac31f95..00000000 --- a/packages/cli/internal/core/profile/schema.go +++ /dev/null @@ -1,407 +0,0 @@ -package profile - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -// sectionPolicy is the single typed access path for one schema-v1 -// (domain/backend) section. The table below is built in Config field order and -// checked against the Backend Catalog during package initialization. -type sectionPolicy struct { - spec catalog.BackendSpec - - valueType reflect.Type - payload func(Profile) (any, bool) - setPayload func(*Profile, json.RawMessage) error - - configValue func(*Config) any - configEmpty func(*Config) bool - defaultName func(*Config) string - names func(*Config) []string - lookup func(*Config, string) (Profile, bool) - write func(*Config, string, Profile, bool) error - remove func(*Config, string) - setDefault func(*Config, string) - - credentialSource func(Profile) string - credentialValue func(*CredentialsFile) any - credentialEmpty func(*CredentialsFile) bool - mergeCredentials func(*Config, *CredentialsFile) - extractCredentials func(*Config, *CredentialsFile) - stripCredentials func(*Config) -} - -func newSectionPolicy[T any]( - spec catalog.BackendSpec, - selectSection func(*Config) *Section[T], - getPayload func(Profile) *T, - setPayload func(*Profile, *T), - allowZeroPayload bool, -) *sectionPolicy { - return §ionPolicy{ - spec: spec, - valueType: reflect.TypeOf((*T)(nil)).Elem(), - payload: func(value Profile) (any, bool) { - payload := getPayload(value) - return payload, payload != nil - }, - setPayload: func(value *Profile, raw json.RawMessage) error { - var payload T - if len(raw) > 0 { - if err := json.Unmarshal(raw, &payload); err != nil { - return err - } - } - setPayload(value, &payload) - return nil - }, - configValue: func(config *Config) any { - section := selectSection(config) - if section == nil { - return nil - } - return *section - }, - configEmpty: func(config *Config) bool { - section := selectSection(config) - return section == nil || section.IsEmpty() - }, - defaultName: func(config *Config) string { - section := selectSection(config) - if section == nil { - return "" - } - return section.Default - }, - names: func(config *Config) []string { - section := selectSection(config) - if section == nil { - return nil - } - return mapKeys(section.Profiles) - }, - lookup: func(config *Config, name string) (Profile, bool) { - section := selectSection(config) - if section == nil { - return Profile{}, false - } - payload, ok := section.Profiles[name] - if !ok { - return Profile{}, false - } - value := Profile{Backend: spec.ID.Name} - setPayload(&value, &payload) - return value, true - }, - write: func(config *Config, name string, value Profile, setDefault bool) error { - section := selectSection(config) - if section == nil { - return invalidProfilePair(spec.ID.Domain, spec.ID.Name) - } - var payload T - selected := getPayload(value) - if selected == nil && !allowZeroPayload { - return cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("profile 缺 %s 的 sub-profile 数据", spec.ID.Name), - ) - } - if selected != nil { - payload = *selected - } - if section.Profiles == nil { - section.Profiles = map[string]T{} - } - section.Profiles[name] = payload - if setDefault || section.Default == "" { - section.Default = name - } - return nil - }, - remove: func(config *Config, name string) { - section := selectSection(config) - if section == nil { - return - } - delete(section.Profiles, name) - if section.Default == name { - section.Default = "" - } - }, - setDefault: func(config *Config, name string) { - section := selectSection(config) - if section != nil { - section.Default = name - } - }, - credentialSource: func(Profile) string { return "" }, - } -} - -func newCredentialSectionPolicy[T, C any]( - spec catalog.BackendSpec, - selectSection func(*Config) *Section[T], - getPayload func(Profile) *T, - setPayload func(*Profile, *T), - selectCredentialSection func(*CredentialsFile) *CredSection[C], - credentialSource func(T) string, - getCredentials func(T) *C, - setCredentials func(*T, *C), -) *sectionPolicy { - policy := newSectionPolicy(spec, selectSection, getPayload, setPayload, false) - policy.credentialSource = func(value Profile) string { - payload := getPayload(value) - if payload == nil { - return "" - } - return credentialSource(*payload) - } - policy.credentialValue = func(credentials *CredentialsFile) any { - section := selectCredentialSection(credentials) - if section == nil { - return nil - } - return *section - } - policy.credentialEmpty = func(credentials *CredentialsFile) bool { - section := selectCredentialSection(credentials) - return section == nil || section.IsEmpty() - } - policy.mergeCredentials = func(config *Config, credentials *CredentialsFile) { - section := selectSection(config) - credentialSection := selectCredentialSection(credentials) - if section == nil || credentialSection == nil { - return - } - for name, payload := range section.Profiles { - if !IsFileSource(credentialSource(payload)) { - continue - } - stored, ok := credentialSection.Profiles[name] - if !ok { - continue - } - copy := stored - setCredentials(&payload, ©) - section.Profiles[name] = payload - } - } - policy.extractCredentials = func(config *Config, credentials *CredentialsFile) { - section := selectSection(config) - credentialSection := selectCredentialSection(credentials) - if section == nil || credentialSection == nil { - return - } - for name, payload := range section.Profiles { - value := getCredentials(payload) - if !IsFileSource(credentialSource(payload)) || value == nil { - continue - } - if credentialSection.Profiles == nil { - credentialSection.Profiles = map[string]C{} - } - credentialSection.Profiles[name] = *value - } - } - policy.stripCredentials = func(config *Config) { - section := selectSection(config) - if section == nil || section.Profiles == nil { - return - } - profiles := make(map[string]T, len(section.Profiles)) - for name, payload := range section.Profiles { - setCredentials(&payload, nil) - profiles[name] = payload - } - section.Profiles = profiles - } - return policy -} - -type sectionPolicyFactory func(catalog.BackendSpec) *sectionPolicy - -var sectionPolicyFactories = map[catalog.ProfileType]sectionPolicyFactory{ - catalog.ProfileTypeDotenv: func(spec catalog.BackendSpec) *sectionPolicy { - return newSectionPolicy( - spec, - func(config *Config) *Section[DotenvProfile] { return &config.EnvDotenv }, - func(value Profile) *DotenvProfile { return value.Dotenv }, - func(value *Profile, payload *DotenvProfile) { value.Dotenv = payload }, - true, - ) - }, - catalog.ProfileTypeInfisical: func(spec catalog.BackendSpec) *sectionPolicy { - return newCredentialSectionPolicy( - spec, - func(config *Config) *Section[InfisicalProfile] { return &config.EnvInfisical }, - func(value Profile) *InfisicalProfile { return value.Infisical }, - func(value *Profile, payload *InfisicalProfile) { value.Infisical = payload }, - func(credentials *CredentialsFile) *CredSection[InfisicalCredentials] { - return &credentials.EnvInfisical - }, - func(value InfisicalProfile) string { return value.CredentialSource }, - func(value InfisicalProfile) *InfisicalCredentials { return value.Credentials }, - func(value *InfisicalProfile, credentials *InfisicalCredentials) { value.Credentials = credentials }, - ) - }, - catalog.ProfileTypeS3: func(spec catalog.BackendSpec) *sectionPolicy { - return newCredentialSectionPolicy( - spec, - func(config *Config) *Section[S3Profile] { return config.S3CompatSection(spec.ID.Name) }, - func(value Profile) *S3Profile { return value.S3 }, - func(value *Profile, payload *S3Profile) { value.S3 = payload }, - func(credentials *CredentialsFile) *CredSection[S3Credentials] { - return credentials.S3CompatCredSection(spec.ID.Name) - }, - func(value S3Profile) string { return value.CredentialSource }, - func(value S3Profile) *S3Credentials { return value.Credentials }, - func(value *S3Profile, credentials *S3Credentials) { value.Credentials = credentials }, - ) - }, - catalog.ProfileTypeKustomize: func(spec catalog.BackendSpec) *sectionPolicy { - return newSectionPolicy( - spec, - func(config *Config) *Section[KustomizeProfile] { return &config.DeployKustomize }, - func(value Profile) *KustomizeProfile { return value.Kustomize }, - func(value *Profile, payload *KustomizeProfile) { value.Kustomize = payload }, - false, - ) - }, - catalog.ProfileTypeVercel: func(spec catalog.BackendSpec) *sectionPolicy { - return newCredentialSectionPolicy( - spec, - func(config *Config) *Section[VercelProfile] { return &config.DeployVercel }, - func(value Profile) *VercelProfile { return value.Vercel }, - func(value *Profile, payload *VercelProfile) { value.Vercel = payload }, - func(credentials *CredentialsFile) *CredSection[VercelCredentials] { - return &credentials.DeployVercel - }, - func(value VercelProfile) string { return value.CredentialSource }, - func(value VercelProfile) *VercelCredentials { return value.Credentials }, - func(value *VercelProfile, credentials *VercelCredentials) { value.Credentials = credentials }, - ) - }, - catalog.ProfileTypeCloudflare: func(spec catalog.BackendSpec) *sectionPolicy { - return newCredentialSectionPolicy( - spec, - func(config *Config) *Section[CloudflareProfile] { return &config.DeployCloudflare }, - func(value Profile) *CloudflareProfile { return value.Cloudflare }, - func(value *Profile, payload *CloudflareProfile) { value.Cloudflare = payload }, - func(credentials *CredentialsFile) *CredSection[CloudflareCredentials] { - return &credentials.DeployCloudflare - }, - func(value CloudflareProfile) string { return value.CredentialSource }, - func(value CloudflareProfile) *CloudflareCredentials { return value.Credentials }, - func(value *CloudflareProfile, credentials *CloudflareCredentials) { value.Credentials = credentials }, - ) - }, - catalog.ProfileTypeEdgeOne: func(spec catalog.BackendSpec) *sectionPolicy { - return newCredentialSectionPolicy( - spec, - func(config *Config) *Section[EdgeOneProfile] { return &config.DeployEdgeOne }, - func(value Profile) *EdgeOneProfile { return value.EdgeOne }, - func(value *Profile, payload *EdgeOneProfile) { value.EdgeOne = payload }, - func(credentials *CredentialsFile) *CredSection[EdgeOneCredentials] { - return &credentials.DeployEdgeOne - }, - func(value EdgeOneProfile) string { return value.CredentialSource }, - func(value EdgeOneProfile) *EdgeOneCredentials { return value.Credentials }, - func(value *EdgeOneProfile, credentials *EdgeOneCredentials) { value.Credentials = credentials }, - ) - }, - catalog.ProfileTypeContainer: func(spec catalog.BackendSpec) *sectionPolicy { - return newCredentialSectionPolicy( - spec, - func(config *Config) *Section[ContainerProfile] { return config.ContainerKindSection(spec.ID.Name) }, - func(value Profile) *ContainerProfile { return value.Container }, - func(value *Profile, payload *ContainerProfile) { value.Container = payload }, - func(credentials *CredentialsFile) *CredSection[ContainerCredentials] { - return credentials.ContainerKindCredSection(spec.ID.Name) - }, - func(value ContainerProfile) string { return value.CredentialSource }, - func(value ContainerProfile) *ContainerCredentials { return value.Credentials }, - func(value *ContainerProfile, credentials *ContainerCredentials) { value.Credentials = credentials }, - ) - }, -} - -var schemaPoliciesOrdered, schemaPoliciesByPair = mustBuildSchemaPolicies() - -func mustBuildSchemaPolicies() ([]*sectionPolicy, map[string]*sectionPolicy) { - backendCatalog := catalog.Builtin() - pairs := configSchemaPairs() - ordered := make([]*sectionPolicy, 0, len(pairs)) - byPair := make(map[string]*sectionPolicy, len(pairs)) - for _, pair := range pairs { - spec, ok := backendCatalog.LookupPair(pair) - if !ok { - panic(fmt.Sprintf("profile: schema section %q is absent from Backend Catalog", pair)) - } - factory, ok := sectionPolicyFactories[spec.Profile.Type] - if !ok { - panic(fmt.Sprintf("profile: schema section %q has unsupported profile type %q", pair, spec.Profile.Type)) - } - policy := factory(spec) - if policy.configValue(&Config{}) == nil { - panic(fmt.Sprintf("profile: schema section %q has no typed Config accessor", pair)) - } - if policy.credentialValue != nil && policy.credentialValue(&CredentialsFile{}) == nil { - panic(fmt.Sprintf("profile: schema section %q has no typed CredentialsFile accessor", pair)) - } - ordered = append(ordered, policy) - byPair[pair] = policy - } - - expected := 0 - for _, spec := range backendCatalog.All() { - if spec.Profile.Type == "" { - continue - } - expected++ - if _, ok := byPair[spec.Pair]; !ok { - panic(fmt.Sprintf("profile: Catalog backend %q has no schema-v1 section", spec.Pair)) - } - } - if len(ordered) != expected { - panic(fmt.Sprintf("profile: schema has %d sections, Catalog has %d profile backends", len(ordered), expected)) - } - return ordered, byPair -} - -func configSchemaPairs() []string { - typeOfConfig := reflect.TypeOf(Config{}) - pairs := make([]string, 0, typeOfConfig.NumField()) - for index := 0; index < typeOfConfig.NumField(); index++ { - name := strings.Split(typeOfConfig.Field(index).Tag.Get("json"), ",")[0] - if strings.Contains(name, "/") { - pairs = append(pairs, name) - } - } - return pairs -} - -func schemaPolicy(domain Domain, backend string) (*sectionPolicy, bool) { - policy, ok := schemaPoliciesByPair[SectionKey(domain, backend)] - return policy, ok -} - -func invalidProfilePair(domain Domain, backend string) error { - return cliErrors.New( - cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("(%s, %s) 不是支持的 (domain, backend) 组合", domain, backend), - ) -} - -func mapKeys[T any](values map[string]T) []string { - out := make([]string, 0, len(values)) - for key := range values { - out = append(out, key) - } - return out -} diff --git a/packages/cli/internal/core/profile/schema_api.go b/packages/cli/internal/core/profile/schema_api.go deleted file mode 100644 index f5d5ae74..00000000 --- a/packages/cli/internal/core/profile/schema_api.go +++ /dev/null @@ -1,172 +0,0 @@ -package profile - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" -) - -// SectionSnapshot is the transport-neutral view of one typed schema-v1 -// section. Payload retains the concrete Section[T] value for JSON rendering; -// callers do not need to know which T belongs to a backend. -type SectionSnapshot struct { - Payload any - Names []string - Default string -} - -// ValidateCatalog verifies that every profile-bearing backend maps to exactly -// one persisted schema-v1 section and that its form paths match the typed Go -// payload. This is the composition-time guard against Catalog/profile drift. -func ValidateCatalog(backendCatalog *catalog.Catalog) error { - if backendCatalog == nil { - return fmt.Errorf("profile: backend catalog is required") - } - for _, spec := range backendCatalog.All() { - if spec.Profile.Type == "" { - if spec.Profile.Configurable { - return fmt.Errorf("profile: backend %s has no profile type", spec.Pair) - } - continue - } - policy, ok := policyForSpec(spec) - if !ok { - return fmt.Errorf( - "profile: backend %s has no schema-v1 policy for type %q", - spec.Pair, - spec.Profile.Type, - ) - } - for _, field := range spec.Profile.Fields { - leaf, ok := profileJSONPathType(policy.valueType, field.Path) - if !ok { - return fmt.Errorf( - "profile: backend %s field %q is absent from %s", - spec.Pair, - field.Path, - policy.valueType, - ) - } - if field.Type == catalog.FieldBoolean && leaf.Kind() != reflect.Bool { - return fmt.Errorf("profile: backend %s field %q must be boolean", spec.Pair, field.Path) - } - if (field.Type == catalog.FieldString || field.Type == catalog.FieldSecret) && leaf.Kind() != reflect.String { - return fmt.Errorf("profile: backend %s field %q must be string", spec.Pair, field.Path) - } - } - } - return nil -} - -// InspectSection returns the typed persisted section selected by spec without -// making application code repeat the profile-shape dispatch table. -func InspectSection(config *Config, spec catalog.BackendSpec) (SectionSnapshot, bool) { - if config == nil { - return SectionSnapshot{}, false - } - policy, ok := policyForSpec(spec) - if !ok { - return SectionSnapshot{}, false - } - return SectionSnapshot{ - Payload: policy.configValue(config), - Names: policy.names(config), - Default: policy.defaultName(config), - }, true -} - -// LookupStored returns one profile from the section selected by spec. -func LookupStored(config *Config, spec catalog.BackendSpec, name string) (Profile, bool) { - if config == nil { - return Profile{}, false - } - policy, ok := policyForSpec(spec) - if !ok { - return Profile{}, false - } - return policy.lookup(config, name) -} - -// Payload returns the concrete typed payload carried by the profile union. -func Payload(spec catalog.BackendSpec, value Profile) (any, bool) { - policy, ok := policyForSpec(spec) - if !ok { - return nil, false - } - return policy.payload(value) -} - -// CredentialSource returns the typed credential-source discriminator for a -// profile. Profile shapes without credentials deliberately return empty. -func CredentialSource(spec catalog.BackendSpec, value Profile) string { - policy, ok := policyForSpec(spec) - if !ok { - return "" - } - return policy.credentialSource(value) -} - -// Decode decodes a backend's JSON payload into the typed profile union. -func Decode(spec catalog.BackendSpec, raw json.RawMessage) (Profile, error) { - value := Profile{Backend: spec.ID.Name} - if err := ReplacePayload(spec, &value, raw); err != nil { - return Profile{}, err - } - return value, nil -} - -// ReplacePayload decodes and replaces only the payload selected by spec. It -// intentionally preserves Profile.Backend so masking can rewrite a union -// value without changing its selected backend identity. -func ReplacePayload(spec catalog.BackendSpec, value *Profile, raw json.RawMessage) error { - if value == nil { - return fmt.Errorf("profile: destination is required") - } - policy, ok := policyForSpec(spec) - if !ok { - return fmt.Errorf("profile: backend %s has no schema-v1 policy", spec.Pair) - } - if err := policy.setPayload(value, raw); err != nil { - return fmt.Errorf("profile: decode %s payload: %w", spec.Pair, err) - } - return nil -} - -func policyForSpec(spec catalog.BackendSpec) (*sectionPolicy, bool) { - policy, ok := schemaPoliciesByPair[spec.Pair] - return policy, ok && policy.spec.Profile.Type == spec.Profile.Type -} - -func profileJSONPathType(root reflect.Type, path string) (reflect.Type, bool) { - current := root - for _, part := range strings.Split(path, "/") { - if part == "" { - return nil, false - } - for current.Kind() == reflect.Pointer { - current = current.Elem() - } - if current.Kind() != reflect.Struct { - return nil, false - } - found := false - for index := 0; index < current.NumField(); index++ { - field := current.Field(index) - if strings.Split(field.Tag.Get("json"), ",")[0] == part { - current = field.Type - found = true - break - } - } - if !found { - return nil, false - } - } - for current.Kind() == reflect.Pointer { - current = current.Elem() - } - return current, true -} diff --git a/packages/cli/internal/core/profile/schema_test.go b/packages/cli/internal/core/profile/schema_test.go deleted file mode 100644 index 81d0d3e4..00000000 --- a/packages/cli/internal/core/profile/schema_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package profile - -import ( - "encoding/json" - "slices" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" -) - -func TestSchemaAPIUsesCatalogSpecForTypedAccess(t *testing.T) { - t.Parallel() - - spec, ok := catalog.Builtin().Lookup(catalog.DomainContainer, "ghcr") - if !ok { - t.Fatal("container/ghcr is absent from Catalog") - } - config := &Config{ContainerGHCR: Section[ContainerProfile]{ - Default: "work", - Profiles: map[string]ContainerProfile{ - "work": {Namespace: "team", CredentialSource: SourceFile}, - }, - }} - snapshot, ok := InspectSection(config, spec) - if !ok || snapshot.Default != "work" || len(snapshot.Names) != 1 || snapshot.Names[0] != "work" { - t.Fatalf("InspectSection() = (%+v, %v)", snapshot, ok) - } - stored, ok := LookupStored(config, spec, "work") - if !ok || stored.Container == nil || stored.Container.Namespace != "team" { - t.Fatalf("LookupStored() = (%+v, %v)", stored, ok) - } - if got := CredentialSource(spec, stored); got != SourceFile { - t.Fatalf("CredentialSource() = %q, want %q", got, SourceFile) - } - - decoded, err := Decode(spec, json.RawMessage(`{"namespace":"next"}`)) - if err != nil { - t.Fatal(err) - } - if decoded.Backend != "ghcr" || decoded.Container == nil || decoded.Container.Namespace != "next" { - t.Fatalf("Decode() = %+v", decoded) - } - decoded.Backend = "preserved" - if err := ReplacePayload(spec, &decoded, json.RawMessage(`{"namespace":"final"}`)); err != nil { - t.Fatal(err) - } - if decoded.Backend != "preserved" || decoded.Container.Namespace != "final" { - t.Fatalf("ReplacePayload() = %+v", decoded) - } -} - -func TestValidateCatalogRejectsTypedFieldDrift(t *testing.T) { - t.Parallel() - - backendCatalog, err := catalog.New(catalog.BackendSpec{ - ID: catalog.BackendID{Domain: catalog.DomainEnv, Name: "infisical"}, - Pair: "env/infisical", - Capabilities: []catalog.Capability{catalog.CapabilityEnvGet}, - Profile: catalog.ProfileSpec{ - Configurable: true, - Type: catalog.ProfileTypeInfisical, - Fields: []catalog.FieldSpec{{ - Path: "credentials/notARealField", InputName: "invalid", Type: catalog.FieldSecret, LabelKey: "test", - }}, - }, - }) - if err != nil { - t.Fatal(err) - } - if err := ValidateCatalog(backendCatalog); err == nil { - t.Fatal("ValidateCatalog() accepted a field absent from the typed payload") - } -} - -func TestSchemaPoliciesCoverCatalogInConfigOrder(t *testing.T) { - wantOrder := configSchemaPairs() - gotOrder := make([]string, 0, len(schemaPoliciesOrdered)) - for _, policy := range schemaPoliciesOrdered { - gotOrder = append(gotOrder, policy.spec.Pair) - } - if !slices.Equal(gotOrder, wantOrder) { - t.Fatalf("schema policy order = %v, want Config order %v", gotOrder, wantOrder) - } - - wantCount := 0 - for _, spec := range catalog.Builtin().All() { - if spec.Profile.Type == "" { - continue - } - wantCount++ - if _, ok := schemaPoliciesByPair[spec.Pair]; !ok { - t.Errorf("Catalog backend %q has no schema policy", spec.Pair) - } - } - if len(schemaPoliciesOrdered) != wantCount { - t.Fatalf("schema policy count = %d, want %d Catalog profile backends", len(schemaPoliciesOrdered), wantCount) - } -} - -func TestSchemaPoliciesProvideUniformCRUD(t *testing.T) { - for _, policy := range schemaPoliciesOrdered { - policy := policy - t.Run(policy.spec.Pair, func(t *testing.T) { - config := &Config{Version: SchemaVersion} - value := profileForSchemaTest(t, policy.spec) - - if err := policy.write(config, "work", value, false); err != nil { - t.Fatalf("write: %v", err) - } - if got := policy.defaultName(config); got != "work" { - t.Fatalf("default = %q, want work", got) - } - stored, ok := policy.lookup(config, "work") - if !ok || stored.Backend != policy.spec.ID.Name { - t.Fatalf("lookup = (%+v, %v), want backend %q", stored, ok, policy.spec.ID.Name) - } - if names := policy.names(config); len(names) != 1 || names[0] != "work" { - t.Fatalf("names = %v, want [work]", names) - } - - policy.remove(config, "work") - if _, ok := policy.lookup(config, "work"); ok { - t.Fatal("removed profile is still present") - } - if got := policy.defaultName(config); got != "" { - t.Fatalf("default after remove = %q, want empty", got) - } - }) - } -} - -func profileForSchemaTest(t *testing.T, spec catalog.BackendSpec) Profile { - t.Helper() - value := Profile{Backend: spec.ID.Name} - switch spec.Profile.Type { - case catalog.ProfileTypeDotenv: - value.Dotenv = &DotenvProfile{} - case catalog.ProfileTypeInfisical: - value.Infisical = &InfisicalProfile{} - case catalog.ProfileTypeS3: - value.S3 = &S3Profile{} - case catalog.ProfileTypeKustomize: - value.Kustomize = &KustomizeProfile{} - case catalog.ProfileTypeVercel: - value.Vercel = &VercelProfile{} - case catalog.ProfileTypeCloudflare: - value.Cloudflare = &CloudflareProfile{} - case catalog.ProfileTypeEdgeOne: - value.EdgeOne = &EdgeOneProfile{} - case catalog.ProfileTypeContainer: - value.Container = &ContainerProfile{} - default: - t.Fatalf("unsupported profile type %q", spec.Profile.Type) - } - return value -} diff --git a/packages/cli/internal/core/profile/store.go b/packages/cli/internal/core/profile/store.go deleted file mode 100644 index 2d804234..00000000 --- a/packages/cli/internal/core/profile/store.go +++ /dev/null @@ -1,398 +0,0 @@ -package profile - -// store.go is the on-disk I/O for the two-file profile layout: -// -// ~/.config/one/config.json — non-sensitive (mode 0600) -// ~/.config/one/credentials.json — secrets only (mode 0600) -// ~/.config/one/cache/... — short-lived tokens (per-file 0600) -// -// Two responsibilities split out so callers can mock the path in tests: -// -// - ConfigPath / CredentialsPath / CacheDir / CachePath — where files live -// - Load / Save — read / write with mode 0600 and parent dir mkdirs -// -// Either file may be missing on first run — Load returns empty objects -// for the missing side rather than erroring. Save creates the parent -// directory if needed (mode 0700) and writes both files atomically via -// temp-file + rename. -// -// Missing files are not an error: the loader returns empty Config / -// CredentialsFile values so first-run `one configure add` writes a -// fresh pair without any read-modify-write dance. - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - - 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/userdirs" -) - -// marshalConfig is the body of Config.MarshalJSON. Builds the JSON -// object key-by-key so empty (domain/backend) sections drop out — -// encoding/json's `omitempty` on a non-pointer struct field would -// always emit the empty section. -func marshalConfig(c Config) ([]byte, error) { - var buf bytes.Buffer - buf.WriteByte('{') - - emit := func(first *bool, key string, raw []byte) { - if !*first { - buf.WriteByte(',') - } - buf.WriteByte('"') - buf.WriteString(key) - buf.WriteString(`":`) - buf.Write(raw) - *first = false - } - - versionRaw, err := json.Marshal(c.Version) - if err != nil { - return nil, err - } - first := true - emit(&first, "version", versionRaw) - - if len(c.Workspaces) > 0 { - raw, err := json.Marshal(c.Workspaces) - if err != nil { - return nil, err - } - emit(&first, "workspaces", raw) - } - - emitSection := func(key string, empty bool, value any) error { - if empty { - return nil - } - raw, err := json.Marshal(value) - if err != nil { - return err - } - emit(&first, key, raw) - return nil - } - for _, policy := range schemaPoliciesOrdered { - if err := emitSection(policy.spec.Pair, policy.configEmpty(&c), policy.configValue(&c)); err != nil { - return nil, err - } - } - buf.WriteByte('}') - return buf.Bytes(), nil -} - -// marshalCredentialsFile is the credentials.json sibling of -// marshalConfig. Same trick — empty sections drop out so a fresh -// credentials.json is just `{"version":1}`. -func marshalCredentialsFile(c CredentialsFile) ([]byte, error) { - var buf bytes.Buffer - buf.WriteByte('{') - emit := func(first *bool, key string, raw []byte) { - if !*first { - buf.WriteByte(',') - } - buf.WriteByte('"') - buf.WriteString(key) - buf.WriteString(`":`) - buf.Write(raw) - *first = false - } - versionRaw, err := json.Marshal(c.Version) - if err != nil { - return nil, err - } - first := true - emit(&first, "version", versionRaw) - - emitSection := func(key string, empty bool, value any) error { - if empty { - return nil - } - raw, err := json.Marshal(value) - if err != nil { - return err - } - emit(&first, key, raw) - return nil - } - for _, policy := range schemaPoliciesOrdered { - if policy.credentialValue == nil { - continue - } - if err := emitSection(policy.spec.Pair, policy.credentialEmpty(&c), policy.credentialValue(&c)); err != nil { - return nil, err - } - } - buf.WriteByte('}') - return buf.Bytes(), nil -} - -// configRoot returns ~/.config/one (XDG-aware on Linux). Used as the -// parent directory for config.json / credentials.json / cache/. -func configRoot() (string, error) { - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "one"), nil - } - home, err := userdirs.Home() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "one"), nil -} - -// ConfigPath returns the absolute path of config.json (~/.config/one/config.json). -func ConfigPath() (string, error) { - root, err := configRoot() - if err != nil { - return "", err - } - return filepath.Join(root, "config.json"), nil -} - -// CredentialsPath returns the absolute path of credentials.json -// (~/.config/one/credentials.json). -func CredentialsPath() (string, error) { - root, err := configRoot() - if err != nil { - return "", err - } - return filepath.Join(root, "credentials.json"), nil -} - -// CacheDir returns the absolute path of the token-cache root -// (~/.config/one/cache). -func CacheDir() (string, error) { - root, err := configRoot() - if err != nil { - return "", err - } - return filepath.Join(root, "cache"), nil -} - -// Load reads config.json + credentials.json and merges them into an -// in-memory Config (with each profile's `Credentials *T` populated -// from credentials.json when credentialSource is "file"). Either file -// may be absent — empty Config / CredentialsFile values are returned. -// -// Returned Config / CredentialsFile are never nil. -func Load() (*Config, *CredentialsFile, error) { - cfgPath, err := ConfigPath() - if err != nil { - return nil, nil, err - } - credPath, err := CredentialsPath() - if err != nil { - return nil, nil, err - } - return LoadAt(cfgPath, credPath) -} - -// LoadAt is the testable variant that takes explicit paths. -func LoadAt(cfgPath, credPath string) (*Config, *CredentialsFile, error) { - cfg, _, err := loadConfigAt(cfgPath) - if err != nil { - return nil, nil, err - } - creds, _, err := loadCredentialsAt(credPath) - if err != nil { - return nil, nil, err - } - mergeCredentials(cfg, creds) - return cfg, creds, nil -} - -func loadConfigAt(path string) (*Config, bool, error) { - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return &Config{Version: SchemaVersion}, true, nil - } - return nil, false, err - } - var probe struct { - Version int `json:"version"` - } - if err := json.Unmarshal(raw, &probe); err != nil { - return nil, false, cliErrors.New(cliErrors.PROFILE_FILE_INVALID, - "~/.config/one/config.json 解析失败:"+err.Error()). - WithContext(map[string]any{"path": path}) - } - if probe.Version < MinSupportedVersion || probe.Version > SchemaVersion { - return nil, false, cliErrors.New(cliErrors.PROFILE_VERSION_UNSUPPORTED, - fmt.Sprintf("config.json schema version 不支持:要求 v%d-v%d,当前 v%d", MinSupportedVersion, SchemaVersion, probe.Version)). - WithContext(map[string]any{ - "path": path, - "version": probe.Version, - }) - } - var cfg Config - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, false, cliErrors.New(cliErrors.PROFILE_FILE_INVALID, - "~/.config/one/config.json 解析失败:"+err.Error()). - WithContext(map[string]any{"path": path}) - } - return &cfg, false, nil -} - -func loadCredentialsAt(path string) (*CredentialsFile, bool, error) { - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return &CredentialsFile{Version: SchemaVersion}, true, nil - } - return nil, false, err - } - var probe struct { - Version int `json:"version"` - } - if err := json.Unmarshal(raw, &probe); err != nil { - return nil, false, cliErrors.New(cliErrors.PROFILE_FILE_INVALID, - "~/.config/one/credentials.json 解析失败:"+err.Error()). - WithContext(map[string]any{"path": path}) - } - if probe.Version < MinSupportedVersion || probe.Version > SchemaVersion { - return nil, false, cliErrors.New(cliErrors.PROFILE_VERSION_UNSUPPORTED, - fmt.Sprintf("credentials.json schema version 不支持:要求 v%d-v%d,当前 v%d", MinSupportedVersion, SchemaVersion, probe.Version)). - WithContext(map[string]any{ - "path": path, - "version": probe.Version, - }) - } - var creds CredentialsFile - if err := json.Unmarshal(raw, &creds); err != nil { - return nil, false, cliErrors.New(cliErrors.PROFILE_FILE_INVALID, - "~/.config/one/credentials.json 解析失败:"+err.Error()). - WithContext(map[string]any{"path": path}) - } - return &creds, false, nil -} - -// mergeCredentials inlines secrets from creds into cfg's profile -// structs. Only profiles whose CredentialSource is empty / "file" get -// merged — other source values are left untouched (resolver will -// surface PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED when consumers need -// the credentials). -func mergeCredentials(cfg *Config, creds *CredentialsFile) { - for _, policy := range schemaPoliciesOrdered { - if policy.mergeCredentials != nil { - policy.mergeCredentials(cfg, creds) - } - } -} - -// extractCredentials is the inverse of mergeCredentials: it splits -// secrets out of cfg's in-memory profiles into a CredentialsFile to -// persist alongside config.json. Only file-sourced profiles -// contribute secrets — others are written as-is to config.json -// without any matching entry in credentials.json. -func extractCredentials(cfg *Config) *CredentialsFile { - creds := &CredentialsFile{Version: SchemaVersion} - for _, policy := range schemaPoliciesOrdered { - if policy.extractCredentials != nil { - policy.extractCredentials(cfg, creds) - } - } - return creds -} - -// Save writes config.json + credentials.json with mode 0600, both -// atomically via temp-file + rename. Credentials are extracted from -// cfg's in-memory profile structs (see extractCredentials), so callers -// only need to mutate the Config and call Save — they don't have to -// keep the two objects in sync manually. -// -// Creates the parent directory if needed (mode 0700, same user-only -// rationale). -// -// Both files are always written, even when one side is empty: writing -// an empty `{"version":1}` skeleton to credentials.json keeps Load -// from treating "no credentials.json" as a sign of a fresh-machine -// state vs an intentional all-non-file-source setup. -func Save(cfg *Config) error { - cfgPath, err := ConfigPath() - if err != nil { - return err - } - credPath, err := CredentialsPath() - if err != nil { - return err - } - return SaveAt(cfg, cfgPath, credPath) -} - -// SaveAt is the testable variant. -func SaveAt(cfg *Config, cfgPath, credPath string) error { - if cfg == nil { - return errors.New("profile: nil config") - } - cfg.Version = SchemaVersion - creds := extractCredentials(cfg) - - cfgDir := filepath.Dir(cfgPath) - if err := os.MkdirAll(cfgDir, 0o700); err != nil { - return err - } - credDir := filepath.Dir(credPath) - if cfgDir != credDir { - if err := os.MkdirAll(credDir, 0o700); err != nil { - return err - } - } - - cfgForFile := configForDisk(cfg) - if err := atomicWrite(&cfgForFile, cfgPath); err != nil { - return err - } - if err := atomicWrite(creds, credPath); err != nil { - return fmt.Errorf("profile: config.json saved but credentials.json write failed: %w", err) - } - return nil -} - -// configForDisk returns a copy of cfg with every profile's -// Credentials field cleared. The result is what gets serialized into -// config.json — keeping inline secrets out of the non-sensitive file -// even if some caller forgot to extractCredentials first. -func configForDisk(cfg *Config) Config { - out := *cfg - for _, policy := range schemaPoliciesOrdered { - if policy.stripCredentials != nil { - policy.stripCredentials(&out) - } - } - return out -} - -// atomicWrite marshals v as pretty JSON, writes to a sibling temp -// file with mode 0600, and renames into place. -func atomicWrite(v any, path string) error { - dir := filepath.Dir(path) - raw, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - tmp, err := os.CreateTemp(dir, ".profile-*.json") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) // no-op after successful rename - if _, err := tmp.Write(raw); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmpPath, 0o600); err != nil { - return err - } - return fsutil.ReplaceFile(tmpPath, path) -} diff --git a/packages/cli/internal/core/profile/types.go b/packages/cli/internal/core/profile/types.go deleted file mode 100644 index 67a07402..00000000 --- a/packages/cli/internal/core/profile/types.go +++ /dev/null @@ -1,545 +0,0 @@ -// Package profile is the machine-level configuration for env / deploy / -// container endpoints + credentials. -// -// Profiles are NOT per-workspace. A user configures their endpoints -// once on their machine (e.g. "I have a work Infisical account and a -// personal one"), then any workspace they `cd` into can pick which -// profile to use. Mirrors how kubectl / aws / gcloud handle multi- -// account / multi-cluster scenarios. -// -// On-disk layout — schema v1 splits AWS-style into two files: -// -// ~/.config/one/ -// ├── config.json # non-sensitive: endpoints, regions, paths, -// │ # default pointers, credentialSource markers -// ├── credentials.json # sensitive: only fields that are actual -// │ # secrets (clientSecret / accessKeySecret / -// │ # registry password) -// └── cache/ # short-lived tokens (e.g. Infisical OIDC) -// └── //.json -// -// Each profile in config.json carries a `credentialSource` discriminator -// telling the resolver where to read the matching secret from. The current implementation only -// implements `file` (look in credentials.json); `env` / `command:` -// / `keyring` are reserved sentinel values that surface -// PROFILE_CREDENTIAL_SOURCE_UNSUPPORTED until they're wired up. -// -// File mode is 0600 on both files; parent dir is 0700. -// -// In-memory shape: profile structs still carry an inlined -// `Credentials *T` field so consumers (envcmd / deploycmd / etc.) keep -// reading `resolved.Profile.X.Credentials.Y`. The split is purely -// physical at the file boundary: store.go's Save zeroes Credentials -// before serializing config.json, and Load reads both files and merges -// credentials back into the in-memory profile. The HTTP handlers in -// internal/transport/http serialize the in-memory shape directly so the web -// UI continues to receive `{ "credentials": {...} }` inline (subject -// to masking when reveal != 1). - -package profile - -import catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - -// SchemaVersion is the on-disk schema version Save always writes. -// Bumped on incompatible shape changes. -// -// The current schema uses per-section profile pointers named `default`. -// Shared one.manifest.json no longer stores profile names. This file owns -// global defaults and keeps the legacy, environment-agnostic per-workspace -// overrides readable; new environment-aware choices live independently in -// profile-bindings.json. -const SchemaVersion = 1 - -// MinSupportedVersion is the oldest on-disk schema this binary still -// reads. Bumped only when an actually-incompatible shape lands. -const MinSupportedVersion = 1 - -// Config is the root document persisted to ~/.config/one/config.json. -// -// Each (domain/backend) is a top-level JSON key with a literal slash -// (Go's json package treats tag values as opaque strings). Section is -// typed to its backend's profile struct so reads are statically -// checked: storage cannot mix an S3 profile into the kustomize -// section. The profile's `Credentials *T` field is omitted at the -// file-write boundary by store.go (configForDisk). -type Config struct { - Version int `json:"version"` - Workspaces map[string]WorkspaceConfig `json:"workspaces,omitempty"` - EnvInfisical Section[InfisicalProfile] `json:"env/infisical,omitempty"` - EnvDotenv Section[DotenvProfile] `json:"env/dotenv,omitempty"` - DeployAliyunOSS Section[S3Profile] `json:"deploy/aliyun-oss,omitempty"` - DeployTencentCOS Section[S3Profile] `json:"deploy/tencent-cos,omitempty"` - DeployAWSS3 Section[S3Profile] `json:"deploy/aws-s3,omitempty"` - DeployMinIO Section[S3Profile] `json:"deploy/minio,omitempty"` - DeployRustFS Section[S3Profile] `json:"deploy/rustfs,omitempty"` - DeployR2 Section[S3Profile] `json:"deploy/r2,omitempty"` - DeployKustomize Section[KustomizeProfile] `json:"deploy/kustomize,omitempty"` - DeployVercel Section[VercelProfile] `json:"deploy/vercel,omitempty"` - DeployCloudflare Section[CloudflareProfile] `json:"deploy/cloudflare,omitempty"` - DeployEdgeOne Section[EdgeOneProfile] `json:"deploy/edgeone,omitempty"` - ContainerDocker Section[ContainerProfile] `json:"container/docker,omitempty"` - ContainerDockerHub Section[ContainerProfile] `json:"container/dockerhub,omitempty"` - ContainerGHCR Section[ContainerProfile] `json:"container/ghcr,omitempty"` - ContainerACR Section[ContainerProfile] `json:"container/acr,omitempty"` -} - -// WorkspaceConfig stores legacy, environment-agnostic machine-local profile -// choices for one shared workspace. The key in Config.Workspaces is -// manifest.workspace.id. -// Profiles maps "domain/backend" (for example "env/infisical") to the -// local profile name that should be used in that workspace. Projects -// optionally overrides those choices for a manifest project name. -type WorkspaceConfig struct { - Name string `json:"name,omitempty"` - Root string `json:"root,omitempty"` - Profiles map[string]string `json:"profiles,omitempty"` - Projects map[string]WorkspaceProjectConfig `json:"projects,omitempty"` -} - -// IsEmpty reports whether the workspace binding has no useful data. -func (w WorkspaceConfig) IsEmpty() bool { - return w.Name == "" && w.Root == "" && len(w.Profiles) == 0 && len(w.Projects) == 0 -} - -// WorkspaceProjectConfig stores per-project machine-local profile -// choices. The key in WorkspaceConfig.Projects is manifest.projects[].name. -type WorkspaceProjectConfig struct { - Profiles map[string]string `json:"profiles,omitempty"` -} - -// IsEmpty reports whether the project binding has no useful data. -func (p WorkspaceProjectConfig) IsEmpty() bool { - return len(p.Profiles) == 0 -} - -// S3CompatSection returns the profile-section for one S3-compatible -// deploy backend. Returns nil for unknown kinds. All six sections share -// the same Section[S3Profile] shape so callers that touch any one of -// them ("upsert profile by name", "list profile names", "set default") -// can dispatch through this single accessor instead of duplicating one -// switch arm per kind. -func (c *Config) S3CompatSection(kind string) *Section[S3Profile] { - switch kind { - case catalog.DeployAliyunOSS: - return &c.DeployAliyunOSS - case catalog.DeployTencentCOS: - return &c.DeployTencentCOS - case catalog.DeployAWSS3: - return &c.DeployAWSS3 - case catalog.DeployMinIO: - return &c.DeployMinIO - case catalog.DeployRustFS: - return &c.DeployRustFS - case catalog.DeployR2: - return &c.DeployR2 - } - return nil -} - -// S3CompatKinds is the canonical ordered list of S3-compatible deploy -// backend ids in Backend Catalog order. Listed once here so callers can range -// over the same protocol family without re-typing the literals. -func S3CompatKinds() []string { - specs := catalog.Builtin().WithTrait(catalog.TraitS3Compatible) - out := make([]string, len(specs)) - for i, spec := range specs { - out[i] = spec.ID.Name - } - return out -} - -// IsS3Compatible reports whether `backend` is one of the six -// S3-protocol-compatible deploy backend ids. Mirrored from -// workspace.IsS3CompatibleDeploy so the profile package does not import -// workspace. -func IsS3Compatible(backend string) bool { - spec, ok := catalog.Builtin().Lookup(catalog.DomainDeploy, backend) - return ok && spec.HasTrait(catalog.TraitS3Compatible) -} - -// ContainerKindSection returns the profile-section for one container -// backend kind. All four kinds share Section[ContainerProfile]; the schema -// policy table uses this one typed storage dispatcher for the family. -func (c *Config) ContainerKindSection(kind string) *Section[ContainerProfile] { - switch kind { - case catalog.ContainerDocker: - return &c.ContainerDocker - case catalog.ContainerDockerHub: - return &c.ContainerDockerHub - case catalog.ContainerGHCR: - return &c.ContainerGHCR - case catalog.ContainerACR: - return &c.ContainerACR - } - return nil -} - -// ContainerKinds returns container backend ids in Backend Catalog order. -func ContainerKinds() []string { - return catalog.Builtin().Names(catalog.DomainContainer) -} - -// IsContainerKind reports whether `backend` is a recognised container -// backend id. Mirrors IsS3Compatible's contract. -func IsContainerKind(backend string) bool { - _, ok := catalog.Builtin().Lookup(catalog.DomainContainer, backend) - return ok -} - -// MarshalJSON drops empty sections from the output so a fresh config renders -// only its version instead of every (domain/backend) key with an empty value. -// Encoding/json's `omitempty` doesn't fire for non-pointer struct fields, so -// the schema policy table emits the object section by section. -func (c Config) MarshalJSON() ([]byte, error) { - return marshalConfig(c) -} - -// CredentialsFile is the root document persisted to -// ~/.config/one/credentials.json. It mirrors Config but only carries -// secret fields, indexed by the same (domain/backend, profile-name) -// keys. Sections without secrets (env/dotenv, deploy/kustomize) don't -// appear here. -type CredentialsFile struct { - Version int `json:"version"` - EnvInfisical CredSection[InfisicalCredentials] `json:"env/infisical,omitempty"` - DeployAliyunOSS CredSection[S3Credentials] `json:"deploy/aliyun-oss,omitempty"` - DeployTencentCOS CredSection[S3Credentials] `json:"deploy/tencent-cos,omitempty"` - DeployAWSS3 CredSection[S3Credentials] `json:"deploy/aws-s3,omitempty"` - DeployMinIO CredSection[S3Credentials] `json:"deploy/minio,omitempty"` - DeployRustFS CredSection[S3Credentials] `json:"deploy/rustfs,omitempty"` - DeployR2 CredSection[S3Credentials] `json:"deploy/r2,omitempty"` - DeployVercel CredSection[VercelCredentials] `json:"deploy/vercel,omitempty"` - DeployCloudflare CredSection[CloudflareCredentials] `json:"deploy/cloudflare,omitempty"` - DeployEdgeOne CredSection[EdgeOneCredentials] `json:"deploy/edgeone,omitempty"` - ContainerDocker CredSection[ContainerCredentials] `json:"container/docker,omitempty"` - ContainerDockerHub CredSection[ContainerCredentials] `json:"container/dockerhub,omitempty"` - ContainerGHCR CredSection[ContainerCredentials] `json:"container/ghcr,omitempty"` - ContainerACR CredSection[ContainerCredentials] `json:"container/acr,omitempty"` -} - -// S3CompatCredSection returns the credentials-section for one -// S3-compatible deploy backend. Sibling of Config.S3CompatSection and the -// single typed credentials-storage dispatcher used by the policy table. -func (c *CredentialsFile) S3CompatCredSection(kind string) *CredSection[S3Credentials] { - switch kind { - case catalog.DeployAliyunOSS: - return &c.DeployAliyunOSS - case catalog.DeployTencentCOS: - return &c.DeployTencentCOS - case catalog.DeployAWSS3: - return &c.DeployAWSS3 - case catalog.DeployMinIO: - return &c.DeployMinIO - case catalog.DeployRustFS: - return &c.DeployRustFS - case catalog.DeployR2: - return &c.DeployR2 - } - return nil -} - -// ContainerKindCredSection returns the credentials-section for one -// container backend kind. Sibling of Config.ContainerKindSection; -// same dispatch model. -func (c *CredentialsFile) ContainerKindCredSection(kind string) *CredSection[ContainerCredentials] { - switch kind { - case catalog.ContainerDocker: - return &c.ContainerDocker - case catalog.ContainerDockerHub: - return &c.ContainerDockerHub - case catalog.ContainerGHCR: - return &c.ContainerGHCR - case catalog.ContainerACR: - return &c.ContainerACR - } - return nil -} - -// MarshalJSON omits empty sections, same trick as Config. -func (c CredentialsFile) MarshalJSON() ([]byte, error) { - return marshalCredentialsFile(c) -} - -// Section is one (domain/backend) bucket in config.json: a default -// pointer + a name-keyed map of typed profiles. The default pointer -// lives per-section (not per-domain) — `deploy/aws-s3.default = "web-prod"` -// and `deploy/kustomize.default = "prod-k8s"` coexist without conflict. -type Section[T any] struct { - Default string `json:"default,omitempty"` - Profiles map[string]T `json:"profiles,omitempty"` -} - -// IsEmpty reports whether the section has no default pointer and no -// profiles. Used by Config.MarshalJSON to suppress empty sections. -func (s Section[T]) IsEmpty() bool { - return s.Default == "" && len(s.Profiles) == 0 -} - -// CredSection is the credentials.json sibling of Section. No `default` -// pointer here — default selection is purely a config-side concern. -type CredSection[T any] struct { - Profiles map[string]T `json:"profiles,omitempty"` -} - -// IsEmpty reports whether the credentials section has no entries. -func (s CredSection[T]) IsEmpty() bool { - return len(s.Profiles) == 0 -} - -// Profile is the resolver's return shape — a discriminated union over -// every backend type we support. Storage no longer uses this struct -// (sections store backend-typed profiles directly); it survives only -// as the in-memory shape `Resolve` hands callers, so consumers -// (envcmd / deploycmd / containercmd) keep reading -// `resolved.Profile.S3` etc. without churn. -// -// S3 is reused as the in-memory slot for all six S3-compatible deploy -// backends (aliyun-oss / tencent-cos / aws-s3 / minio / rustfs / r2). -// They share the same S3Profile shape; Backend discriminates which one -// the resolver matched. -type Profile struct { - Backend string `json:"backend,omitempty"` - Infisical *InfisicalProfile `json:"infisical,omitempty"` - Dotenv *DotenvProfile `json:"dotenv,omitempty"` - Kustomize *KustomizeProfile `json:"kustomize,omitempty"` - S3 *S3Profile `json:"s3,omitempty"` - Vercel *VercelProfile `json:"vercel,omitempty"` - Cloudflare *CloudflareProfile `json:"cloudflare,omitempty"` - EdgeOne *EdgeOneProfile `json:"edgeone,omitempty"` - Container *ContainerProfile `json:"container,omitempty"` -} - -// CredentialSource discriminates where the resolver should fetch a -// profile's secrets from. The current implementation only supports SourceFile; the rest are -// sentinels reserved for future wiring (env / external command / -// system keyring). -const ( - SourceFile = "file" - SourceEnv = "env" - SourceCommand = "command:" // prefix; full value e.g. "command:op-cli read ..." - SourceKeyring = "keyring" -) - -// IsFileSource reports whether `s` selects the file-backed credential -// loader. Empty string is treated as file (default for newly-added -// profiles that don't set the field explicitly). -func IsFileSource(s string) bool { - return s == "" || s == SourceFile -} - -// InfisicalProfile carries the machine-level Infisical-instance -// identity: which site (saas vs self-hosted) + the credentials to -// authenticate as. Project-level fields (projectId, environments, -// rootPath) live in the workspace's one.manifest.json#env block — a -// single profile drives many workspaces. -// -// In-memory: Credentials is populated by Load (read from -// credentials.json). On-disk: store.go's configForDisk zeroes -// Credentials before serializing config.json so secrets never leak -// into the non-sensitive file. -type InfisicalProfile struct { - SiteURL string `json:"siteUrl"` - CredentialSource string `json:"credentialSource,omitempty"` - Credentials *InfisicalCredentials `json:"credentials,omitempty"` -} - -// InfisicalCredentials holds Universal Auth machine-identity creds. -type InfisicalCredentials struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` -} - -// DotenvProfile is intentionally minimal — dotenv has no remote / -// no schema, so a "profile" for dotenv is just a name. Useful for -// users who want a uniform `one env --profile ` UX. -// -// Has no credentials. -type DotenvProfile struct{} - -// KustomizeProfile carries the Kubernetes connection file used by a -// `deploy/kustomize` deployment target. K8s cluster auth lives in -// ~/.kube/config; this profile only points at which kubeconfig + -// context to use, so it has no inline credentials. -type KustomizeProfile struct { - KubeconfigPath string `json:"kubeconfigPath,omitempty"` - KubeconfigContext string `json:"kubeconfigContext,omitempty"` -} - -// S3Profile is the machine-level config shared by all six S3-compatible -// deploy backends (deploy/aliyun-oss, deploy/tencent-cos, deploy/aws-s3, -// deploy/minio, deploy/rustfs, deploy/r2). They all speak the standard -// S3 protocol with AccessKey-pair auth; the only difference is which -// vendor's endpoint they point at, surfaced via the user-facing backend -// id rather than via the profile schema. -// -// Bucket is per-subproject (projects[i].deploy.bucket) — one credential -// reaches many buckets, so binding bucket to the profile would force -// one profile per bucket. -type S3Profile struct { - Endpoint string `json:"endpoint,omitempty"` - Region string `json:"region,omitempty"` - ForcePathStyle bool `json:"forcePathStyle,omitempty"` - CredentialSource string `json:"credentialSource,omitempty"` - Credentials *S3Credentials `json:"credentials,omitempty"` -} - -// S3Credentials is the AccessKey pair shared by every S3-compatible -// deploy backend — Aliyun OSS / Tencent COS / AWS S3 / MinIO / RustFS / -// Cloudflare R2 all authenticate with AKID + secret. -type S3Credentials struct { - AccessKeyID string `json:"accessKeyId"` - AccessKeySecret string `json:"accessKeySecret"` -} - -// VercelProfile is the deploy/vercel backend's machine-level config. -// Single profile drives many workspaces — one Vercel personal token / -// team-scoped token reaches every project under that account / team. -// -// Per-project Vercel project linkage (Project ID + Project name) -// lives in manifest.projects[i].deploy.vercel and is set up by -// `vercel link` / `vercel pull` the first time a project deploys. -// -// Team is the optional org slug passed via `--scope`; empty means -// "personal scope" (the token owner's account). -type VercelProfile struct { - Team string `json:"team,omitempty"` - CredentialSource string `json:"credentialSource,omitempty"` - Credentials *VercelCredentials `json:"credentials,omitempty"` -} - -// VercelCredentials holds the Vercel API token. Created in -// vercel.com → Account Settings → Tokens, scoped to either a personal -// account or a team. The token is the only credential — Vercel's CLI -// authenticates entirely via this token. -type VercelCredentials struct { - APIToken string `json:"apiToken"` -} - -// CloudflareProfile is the deploy/cloudflare backend's machine-level -// config. Single profile drives many workspaces — one Cloudflare API -// token reaches every Worker / static asset bundle under the account it -// scopes to. -// -// Per-project Worker / Pages name lives in -// manifest.projects[i].deploy.cloudflare; wrangler.toml inside the -// project is the source of truth wrangler itself reads. -// -// AccountID is the optional account scope. wrangler will read -// CLOUDFLARE_ACCOUNT_ID from the environment when set; required only on -// multi-account tokens. Empty means "use the token's only account". -type CloudflareProfile struct { - AccountID string `json:"accountId,omitempty"` - CredentialSource string `json:"credentialSource,omitempty"` - Credentials *CloudflareCredentials `json:"credentials,omitempty"` -} - -// CloudflareCredentials holds the Cloudflare API token. Created in -// dash.cloudflare.com → My Profile → API Tokens, with at minimum the -// Edit Workers permission. wrangler reads it from CLOUDFLARE_API_TOKEN -// at exec time so the token never appears on argv. -type CloudflareCredentials struct { - APIToken string `json:"apiToken"` -} - -// EdgeOneProfile is the deploy/edgeone backend's machine-level config. -// EdgeOne is Tencent Cloud's edge platform — single profile drives -// many workspaces under one Tencent account. -// -// Region is the optional Tencent Cloud region slug (e.g. ap-guangzhou, -// ap-shanghai). Empty defers to whatever the edgeone CLI picks based -// on the project's binding. -type EdgeOneProfile struct { - Region string `json:"region,omitempty"` - CredentialSource string `json:"credentialSource,omitempty"` - Credentials *EdgeOneCredentials `json:"credentials,omitempty"` -} - -// EdgeOneCredentials holds the EdgeOne Pages API token used by the -// upstream `edgeone pages deploy --token` flow. -type EdgeOneCredentials struct { - APIToken string `json:"apiToken"` -} - -// ContainerProfile carries a container-registry endpoint + push -// credentials. Single shape covers every registry that speaks the -// standard registry protocol with HTTP Basic auth (username + token). -// Four backend kinds share this shape today: -// - "docker" — user-supplied Registry host (Harbor / self-hosted / etc.) -// - "dockerhub" — host fixed to "index.docker.io"; Registry is ignored -// - "ghcr" — host fixed to "ghcr.io"; Registry is ignored -// - "acr" — Aliyun ACR; host derived from Region as -// "registry..aliyuncs.com" -// -// The host-derivation logic lives in infra/docker.ResolveRegistry; the -// profile only stores the user-supplied raw inputs. -type ContainerProfile struct { - Registry string `json:"registry,omitempty"` - Region string `json:"region,omitempty"` - Namespace string `json:"namespace,omitempty"` - CredentialSource string `json:"credentialSource,omitempty"` - Credentials *ContainerCredentials `json:"credentials,omitempty"` -} - -// ContainerCredentials holds the registry login pair. Username is the -// account / RAM AKID / robot name; Password is the PAT / RAM secret / -// access token used by `docker login --password-stdin`. -type ContainerCredentials struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// Domain identifies the top-level grouping for a backend. Every -// concrete (domain, backend) pair maps to one Section in Config. -type Domain = catalog.Domain - -const ( - DomainEnv = catalog.DomainEnv - DomainDeploy = catalog.DomainDeploy - DomainContainer = catalog.DomainContainer -) - -// SupportedDomains returns the Backend Catalog's stable domain order. -func SupportedDomains() []Domain { - return catalog.Domains() -} - -// BackendsForDomain returns the list of backend names the schema -// recognises under the given domain. Used by validation + by the -// CRUD `add` command's interactive backend picker. -func BackendsForDomain(domain Domain) []string { - specs := catalog.Builtin().ForDomain(domain) - // Profile mutation historically considers configurable backends before - // local-only ones (today env/infisical before env/dotenv). Preserve that - // behavior as a data-driven ordering rule rather than another id list. - out := make([]string, 0, len(specs)) - for _, spec := range specs { - if spec.Profile.Configurable { - out = append(out, spec.ID.Name) - } - } - for _, spec := range specs { - if !spec.Profile.Configurable { - out = append(out, spec.ID.Name) - } - } - return out -} - -// BackendDomain returns the domain that owns a bare backend name. -// "" for unknown values. -func BackendDomain(backend string) Domain { - for _, domain := range catalog.Domains() { - if _, ok := catalog.Builtin().Lookup(domain, backend); ok { - return domain - } - } - return "" -} - -// SectionKey is the top-level JSON key for a (domain, backend) pair, -// e.g. "env/infisical", "deploy/aws-s3". Useful for diagnostics + error -// messages so users can grep their config.json by the same string -// they see in the error envelope. -func SectionKey(domain Domain, backend string) string { - return string(domain) + "/" + backend -} diff --git a/packages/cli/internal/core/template/compat.go b/packages/cli/internal/core/template/compat.go index 07bc8bb6..54890768 100644 --- a/packages/cli/internal/core/template/compat.go +++ b/packages/cli/internal/core/template/compat.go @@ -19,9 +19,10 @@ package template // domain (mobile/library/electron for "deploy") import ( - "fmt" "sort" "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // Warning is one compat-mismatch line surfaced to the user. @@ -45,12 +46,12 @@ func (w Warning) Message() string { allowed := strings.Join(w.AllowedIDs, ", ") switch { case w.SubprojectName != "": - return fmt.Sprintf( - "工作区当前 %s/%s 与 subproject %q(模板 %s)不兼容;该模板的 %s 域支持: [%s]", + return i18n.Tf( + "template.compat.project", w.Domain, w.SelectedID, w.SubprojectName, w.TemplateID, w.Domain, allowed) default: - return fmt.Sprintf( - "工作区当前 %s/%s 不适用于模板 %s;该模板的 %s 域支持: [%s]。建议手动调整 manifest 切到 %s,或保持现状(仅其他 subproject 走 %s)", + return i18n.Tf( + "template.compat.workspace", w.Domain, strings.TrimPrefix(w.SelectedID, w.Domain+"/"), w.TemplateID, w.Domain, allowed, firstID(w.AllowedIDs), w.Domain, diff --git a/packages/cli/internal/core/template/electron_template_test.go b/packages/cli/internal/core/template/electron_template_test.go new file mode 100644 index 00000000..8e317d91 --- /dev/null +++ b/packages/cli/internal/core/template/electron_template_test.go @@ -0,0 +1,59 @@ +package template + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestElectronSandboxProfileUsesInstalledBinaryWithoutDisablingSandbox(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + root := t.TempDir() + if err := Render("electron-app", root, CommonVariables("desktop", "pnpm")); err != nil { + t.Fatal(err) + } + project := filepath.Join(root, "apps/electron") + module := filepath.Join(project, "node_modules/electron") + if err := os.MkdirAll(module, 0o755); err != nil { + t.Fatal(err) + } + binary := filepath.ToSlash(filepath.Join(root, "electron [dev]*", "electron")) + value, _ := json.Marshal(binary) + if err := os.WriteFile(filepath.Join(module, "index.js"), []byte("module.exports = "+string(value)), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(node, "script/sandbox-profile.mjs") + cmd.Dir = project + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("profile generator: %v\n%s", err, out) + } + for _, want := range []string{"abi ", "profile one-electron-", "userns,", `electron \[dev\]\*`} { + if !strings.Contains(string(out), want) { + t.Errorf("missing %q in %s", want, out) + } + } + if strings.Contains(string(out), "sudo") || strings.Contains(string(out), "chmod") { + t.Fatalf("profile contains shell commands: %s", out) + } + // Production/default development keeps Chromium's sandbox enabled. + for _, rel := range []string{"package.json", "apps/electron/package.json", "apps/electron/src/index.ts", "apps/electron/src/windows/main.window.ts"} { + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "--no-sandbox") || strings.Contains(string(raw), "sandbox: false") { + t.Errorf("sandbox disabled in %s", rel) + } + } + // The generator leaves applying privileges to the administrator. + if _, err := os.Stat(filepath.Join(root, "one-electron.apparmor")); !os.IsNotExist(err) { + t.Fatal("generator wrote a profile automatically") + } +} diff --git a/packages/cli/internal/core/template/list.go b/packages/cli/internal/core/template/list.go index 25bb1cbb..5feaa6de 100644 --- a/packages/cli/internal/core/template/list.go +++ b/packages/cli/internal/core/template/list.go @@ -6,6 +6,8 @@ import ( "io" "sort" "text/tabwriter" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // ListResult is the JSON payload for `one templates`. Schema: @@ -22,7 +24,7 @@ type ListResult struct { // stdout is non-TTY (output.Emit auto-dispatches based on mode). func (r *ListResult) RenderTTY(w io.Writer) { if r == nil || len(r.Templates) == 0 { - fmt.Fprintln(w, "No templates registered.") + fmt.Fprintln(w, i18n.T("templates.empty")) return } // Group by category, then sort by id within each category. The @@ -47,14 +49,14 @@ func (r *ListResult) RenderTTY(w io.Writer) { } tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "CATEGORY\tID\tTOOLCHAIN\tDESCRIPTION") + fmt.Fprintln(tw, i18n.T("templates.headings")) for _, cat := range order { for _, t := range grouped[cat] { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", cat, t.ID, t.Toolchain, t.Description) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", displayCategory(cat), t.ID, t.Toolchain, t.DisplayDescription()) } } tw.Flush() - fmt.Fprintf(w, "\n%d templates. JSON: `one templates -o json`\n", r.Total) + fmt.Fprintf(w, i18n.T("templates.count"), r.Total) } // List returns the registry payload ready to emit. It does not consult @@ -70,3 +72,19 @@ func List(ctx context.Context) (*ListResult, error) { Templates: registry.Templates, }, nil } + +func (t Template) DisplayName() string { + return i18n.LocalizedValue("template."+t.ID+".name", t.Name) +} + +func (t Template) DisplayDescription() string { + return i18n.LocalizedValue("template."+t.ID+".description", t.Description) +} + +func displayCategory(category string) string { + key := "template.category." + category + if label := i18n.T(key); label != key { + return label + } + return category +} diff --git a/packages/cli/internal/core/template/registry.go b/packages/cli/internal/core/template/registry.go index 4cbbca03..5ec84133 100644 --- a/packages/cli/internal/core/template/registry.go +++ b/packages/cli/internal/core/template/registry.go @@ -6,7 +6,6 @@ package template import ( "context" "encoding/json" - "fmt" "io" "net/http" "os" @@ -15,6 +14,7 @@ import ( "time" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled" ) @@ -181,20 +181,20 @@ func loadHTTP(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, "注册表拉取失败,请检查网络连接。") + return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, i18n.T("registry.fetch_failed")) } resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, "注册表拉取失败,请检查网络连接。") + return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, i18n.T("registry.fetch_failed")) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, - fmt.Sprintf("注册表拉取失败(HTTP %d)。请检查网络连接。", resp.StatusCode)) + i18n.Tf("registry.http_failed", resp.StatusCode)) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, "注册表读取失败。") + return nil, cliErrors.New(cliErrors.REGISTRY_FETCH_FAILED, i18n.T("registry.read_failed")) } return body, nil } @@ -209,11 +209,11 @@ func loadLocal(p string) ([]byte, error) { } if _, err := os.Stat(abs); err != nil { return nil, cliErrors.New(cliErrors.REGISTRY_NOT_FOUND, - fmt.Sprintf("找不到本地注册表文件: %s", abs)) + i18n.Tf("registry.file_missing", abs)) } body, err := os.ReadFile(abs) if err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, "registry.json 格式不正确。") + return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, i18n.T("registry.invalid")) } return body, nil } @@ -221,33 +221,30 @@ func loadLocal(p string) ([]byte, error) { func parseAndValidate(raw []byte) (*Registry, error) { var doc map[string]json.RawMessage if err := json.Unmarshal(raw, &doc); err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, "registry.json 格式不正确。") + return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, i18n.T("registry.invalid")) } versionRaw, ok := doc["version"] if !ok { - return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, "registry.json 缺少有效 version。") + return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, i18n.T("registry.version_missing")) } var version int if err := json.Unmarshal(versionRaw, &version); err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, "registry.json 缺少有效 version。") + return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, i18n.T("registry.version_missing")) } if version != RegistryVersion { - msg := fmt.Sprintf("registry.json 版本 %d 不支持,当前 CLI 仅认 v%d。", version, RegistryVersion) + msg := i18n.Tf("registry.version_unsupported", version, RegistryVersion) if version == 0 { - msg += " 当前 schema 把每个模板的 defaults / compat 合并进 domains: " + - "`defaults.` + `compat.` → " + - "`domains.: { default: \"\", compat: [\"\", ...] }`。" + - "toolchain 字段为必填。改完同步把顶层 \"version\" 字段改为 1。" + msg += i18n.T("registry.migrate") } return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, msg) } templatesRaw, ok := doc["templates"] if !ok { - return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, "registry.json 缺少有效 templates 数组。") + return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, i18n.T("registry.templates_missing")) } var rawTemplates []map[string]json.RawMessage if err := json.Unmarshal(templatesRaw, &rawTemplates); err != nil { - return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, "registry.json 缺少有效 templates 数组。") + return nil, cliErrors.New(cliErrors.REGISTRY_INVALID, i18n.T("registry.templates_missing")) } templates := make([]Template, 0, len(rawTemplates)) @@ -276,58 +273,58 @@ func validateTemplate(raw map[string]json.RawMessage, index int) (Template, erro var t Template if err := unmarshalString(raw, "id", &t.ID); err != nil || t.ID == "" { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板缺少有效 id(index=%d)。", index)) + i18n.Tf("registry.id_missing", index)) } if err := unmarshalString(raw, "code", &t.Code); err != nil || !isValidTemplateCode(t.Code) { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板缺少有效 code(id=%s):必须为 2 字符 [a-z0-9]。", t.ID)) + i18n.Tf("registry.code_invalid", t.ID)) } if err := unmarshalString(raw, "name", &t.Name); err != nil || t.Name == "" { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板缺少有效 name(id=%s)。", t.ID)) + i18n.Tf("registry.name_missing", t.ID)) } if err := unmarshalString(raw, "description", &t.Description); err != nil { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板缺少有效 description(id=%s)。", t.ID)) + i18n.Tf("registry.description_missing", t.ID)) } var category string if err := unmarshalString(raw, "category", &category); err != nil { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 category 非法(id=%s)。", t.ID)) + i18n.Tf("registry.category_invalid", t.ID)) } t.Category = Category(category) if _, ok := validCategories[t.Category]; !ok { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 category 非法(id=%s)。", t.ID)) + i18n.Tf("registry.category_invalid", t.ID)) } if tagsRaw, ok := raw["tags"]; ok { if err := json.Unmarshal(tagsRaw, &t.Tags); err != nil { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 tags 非法(id=%s)。", t.ID)) + i18n.Tf("registry.tags_invalid", t.ID)) } } else { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 tags 非法(id=%s)。", t.ID)) + i18n.Tf("registry.tags_invalid", t.ID)) } if err := unmarshalString(raw, "repo", &t.Repo); err != nil || t.Repo == "" { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板缺少有效 repo(id=%s)。", t.ID)) + i18n.Tf("registry.repo_missing", t.ID)) } // toolchain is required in the current schema. toolchainRaw, ok := raw["toolchain"] if !ok { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板缺少 toolchain(id=%s),当前 schema 要求 toolchain 为必填。", t.ID)) + i18n.Tf("registry.toolchain_missing", t.ID)) } var toolchain string if err := json.Unmarshal(toolchainRaw, &toolchain); err != nil { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 toolchain 非法(id=%s)。", t.ID)) + i18n.Tf("registry.toolchain_invalid", t.ID)) } t.Toolchain = Toolchain(toolchain) if _, ok := validToolchains[t.Toolchain]; !ok { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 toolchain 非法(id=%s)。", t.ID)) + i18n.Tf("registry.toolchain_invalid", t.ID)) } // Unified domains block: `domains: { : { default, compat } }` // is the on-disk shape; we flatten it to t.Defaults / t.Compat for the @@ -336,7 +333,7 @@ func validateTemplate(raw map[string]json.RawMessage, index int) (Template, erro var domains map[string]templateDomainSpec if err := json.Unmarshal(dRaw, &domains); err != nil { return t, cliErrors.New(cliErrors.REGISTRY_INVALID, - fmt.Sprintf("registry.json 模板 domains 非法(id=%s)。", t.ID)) + i18n.Tf("registry.domains_invalid", t.ID)) } for name, spec := range domains { if spec.Default != "" { @@ -359,7 +356,7 @@ func validateTemplate(raw map[string]json.RawMessage, index int) (Template, erro func unmarshalString(raw map[string]json.RawMessage, key string, dst *string) error { v, ok := raw[key] if !ok { - return fmt.Errorf("%s missing", key) + return i18n.Errorf("registry.field_missing", key) } return json.Unmarshal(v, dst) } diff --git a/packages/cli/internal/core/template/render.go b/packages/cli/internal/core/template/render.go index 584a391c..bc64c5f3 100644 --- a/packages/cli/internal/core/template/render.go +++ b/packages/cli/internal/core/template/render.go @@ -11,6 +11,7 @@ import ( "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/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled" ) @@ -37,15 +38,22 @@ func CommonVariables(projectName, packageManager string) Variables { // 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. +// 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": {}, + ".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": {}, } // pathVarRE matches the __varName__ placeholder syntax used in @@ -68,7 +76,7 @@ func Render(templateID string, targetDir string, vars Variables) error { // 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, - fmt.Sprintf("本地模板不存在:%s。请确认 templates/%s 已存在。", templateID, templateID)) + i18n.Tf("template.local_missing", templateID, templateID)) } if err := os.MkdirAll(targetDir, 0o755); err != nil { return err @@ -115,7 +123,7 @@ func renderEmbeddedTree(srcRoot string, dstRoot string, vars Variables, isRoot b dstName = strings.TrimSuffix(renderedName, ".hbs") } // Agent instructions belong to the user, including templated filenames. - if dstName == "AGENTS.md" || dstName == "CLAUDE.md" { + if dstName == "AGENTS.md" || dstName == "CLAUDE.md" || isNodeLockfile(dstName) { continue } dstPath := filepath.Join(dstRoot, dstName) @@ -131,7 +139,7 @@ func renderEmbeddedTree(srcRoot string, dstRoot string, vars Variables, isRoot b rendered, rerr := renderHandlebars(string(raw), vars) if rerr != nil { return cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, - fmt.Sprintf("模板渲染失败 %s: %v", srcPath, rerr)) + i18n.Tf("template.render_failed", srcPath, rerr)) } body = []byte(rendered) } else { @@ -240,3 +248,11 @@ func capFirst(s string) string { 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 + } + return false +} diff --git a/packages/cli/internal/core/workspace/backend.go b/packages/cli/internal/core/workspace/backend.go index bddb5a6c..f6993cc0 100644 --- a/packages/cli/internal/core/workspace/backend.go +++ b/packages/cli/internal/core/workspace/backend.go @@ -18,30 +18,8 @@ import ( const ( EnvBackendDotenv = catalog.EnvDotenv EnvBackendInfisical = catalog.EnvInfisical - - DeployBackendKustomize = catalog.DeployKustomize - DeployBackendAliyunOSS = catalog.DeployAliyunOSS - DeployBackendTencentCOS = catalog.DeployTencentCOS - DeployBackendAWSS3 = catalog.DeployAWSS3 - DeployBackendMinIO = catalog.DeployMinIO - DeployBackendRustFS = catalog.DeployRustFS - DeployBackendR2 = catalog.DeployR2 - DeployBackendVercel = catalog.DeployVercel - DeployBackendCloudflare = catalog.DeployCloudflare - DeployBackendEdgeOne = catalog.DeployEdgeOne - - ContainerBackendDocker = catalog.ContainerDocker ) -// IsS3CompatibleDeploy reports whether `kind` names one of the -// S3-protocol-compatible deploy backends. All six share the same on-disk -// profile shape and the same Apply implementation in adapters/deploy/s3compat; -// only the user-facing id, defaults, and prompts differ. -func IsS3CompatibleDeploy(kind string) bool { - spec, ok := catalog.Builtin().Lookup(catalog.DomainDeploy, kind) - return ok && spec.HasTrait(catalog.TraitS3Compatible) -} - // EnvBackend returns the bare backend name selected for the workspace's // env domain ("dotenv" / "infisical"), or "" if unset. func EnvBackend(m *Manifest) string { @@ -62,112 +40,6 @@ func EnvConfigRaw(m *Manifest) json.RawMessage { return m.Domains.Env.Config } -// DeploySelection is the deploy backend chosen for one project. -type DeploySelection struct { - Backend string // bare backend name, e.g. "kustomize" or "s3" -} - -// DeployForProject returns the deploy backend configured for a named -// project, or the zero value when nothing is configured. -func DeployForProject(m *Manifest, projectName string) DeploySelection { - dep := projectDeploy(m, projectName) - if dep == nil || dep.Kind == "" { - return DeploySelection{} - } - return DeploySelection{Backend: dep.Kind} -} - -// DeployConfigRawForProject returns the raw JSON of a project's deploy -// backend kind-specific config, suitable for unmarshalling into a typed -// per-kind config struct. Returns nil when no deploy section / no config -// has been written. -func DeployConfigRawForProject(m *Manifest, projectName string) json.RawMessage { - dep := projectDeploy(m, projectName) - if dep == nil { - return nil - } - return dep.Config -} - -// ContainerForProject reports whether the named project has a container -// backend configured (i.e. its Dockerfile is owned by this workspace). -// Also returns any per-project image override. -func ContainerForProject(m *Manifest, projectName string) (enabled bool, imageOverride string) { - c := projectContainer(m, projectName) - if c == nil { - return false, "" - } - return true, c.Image -} - -// ContainerKindForProject returns the container backend kind for the -// named project. Resolution order: -// 1. projects[i].domains.container.kind -// 2. manifest.domains.container.kind (workspace-level default) -// 3. ContainerBackendDocker ("docker") as the implicit fallback -// -// Empty / unknown values normalise to "docker" so callers can pass the -// result straight to the container service without nil-checking. -func ContainerKindForProject(m *Manifest, projectName string) string { - if c := projectContainer(m, projectName); c != nil { - if kind := strings.TrimSpace(c.Kind); kind != "" { - return kind - } - } - if m != nil && m.Domains != nil && m.Domains.Container != nil { - if kind := strings.TrimSpace(m.Domains.Container.Kind); kind != "" { - return kind - } - } - return ContainerBackendDocker -} - -// ContainerNamespaceForProject returns the registry namespace configured -// for the named project (projects[i].domains.container.namespace), or "" -// when unset. Used to compose the image tag -// `/[/]:`. -func ContainerNamespaceForProject(m *Manifest, projectName string) string { - c := projectContainer(m, projectName) - if c == nil { - return "" - } - return c.Namespace -} - -// ExplicitDeployBucketForProject returns the S3 bucket explicitly -// configured at projects[i].domains.deploy.config.bucket, or "" when -// unset. -func ExplicitDeployBucketForProject(m *Manifest, projectName string) string { - dep := projectDeploy(m, projectName) - if dep == nil || len(dep.Config) == 0 { - return "" - } - cfg := struct { - Bucket string `json:"bucket,omitempty"` - }{} - if err := json.Unmarshal(dep.Config, &cfg); err != nil { - return "" - } - return strings.TrimSpace(cfg.Bucket) -} - -// DeployBucketForProject returns the effective S3 bucket for the named -// project. Explicit projects[i].domains.deploy.config.bucket wins; when -// the project targets an S3-compatible deploy backend (aliyun-oss / -// tencent-cos / aws-s3 / minio / rustfs / r2) and no bucket is set, -// workspace.id is used so S3-backed templates do not need a second -// bucket prompt. -func DeployBucketForProject(m *Manifest, projectName string) string { - dep := projectDeploy(m, projectName) - if dep == nil || !IsS3CompatibleDeploy(dep.Kind) { - return "" - } - if bucket := ExplicitDeployBucketForProject(m, projectName); bucket != "" { - return bucket - } - return WorkspaceID(m) -} - // WorkspaceID returns the workspace identity id, or "" when older // manifests have not been back-filled yet. func WorkspaceID(m *Manifest) string { @@ -177,49 +49,6 @@ func WorkspaceID(m *Manifest) string { return strings.TrimSpace(m.Workspace.ID) } -// ExplicitDeployNamespace returns the workspace-level k8s namespace -// configured at manifest.domains.deploy.config.namespace, or "" when -// unset. -func ExplicitDeployNamespace(m *Manifest) string { - cfg := workspaceDeployConfig(m) - if cfg == nil { - return "" - } - return strings.TrimSpace(cfg.Namespace) -} - -// DeployNamespace returns the effective k8s namespace. An explicit -// manifest.domains.deploy.config.namespace wins; otherwise the -// workspace.id is used so new workspaces do not need a second namespace -// prompt. -func DeployNamespace(m *Manifest) string { - if ns := ExplicitDeployNamespace(m); ns != "" { - return ns - } - return WorkspaceID(m) -} - -// DeployKustomizationPath returns the workspace-level kustomize overlay -// base path (manifest.domains.deploy.config.kustomizationPath), or "" when -// unset (callers fall back to the kustomize package's default). -func DeployKustomizationPath(m *Manifest) string { - cfg := workspaceDeployConfig(m) - if cfg == nil { - return "" - } - return cfg.KustomizationPath -} - -// ContainerPlatform returns the workspace-level target image platform -// used by `one container build`, e.g. "linux/amd64". -func ContainerPlatform(m *Manifest) string { - cfg := workspaceContainerConfig(m) - if cfg == nil { - return "" - } - return cfg.Platform -} - // SelectionForProject collapses the workspace-level domain selections and // any per-project container / deploy overrides into a single map keyed by // domain ("container" / "deploy" / "env"). Empty values are dropped so @@ -238,15 +67,6 @@ func SelectionForProject(m *Manifest, project *ManifestProject) map[string]strin if backend := EnvBackend(m); backend != "" { out["env"] = "env/" + backend } - if project != nil { - if enabled, _ := ContainerForProject(m, project.Name); enabled { - out["container"] = "container/" + ContainerKindForProject(m, project.Name) - } - sel := DeployForProject(m, project.Name) - if sel.Backend != "" { - out["deploy"] = "deploy/" + sel.Backend - } - } return out } @@ -273,22 +93,6 @@ func projectDomains(m *Manifest, projectName string) *ProjectDomains { return p.Domains } -func projectDeploy(m *Manifest, projectName string) *ProjectDeployBackend { - d := projectDomains(m, projectName) - if d == nil { - return nil - } - return d.Deploy -} - -func projectContainer(m *Manifest, projectName string) *ProjectContainerOverride { - d := projectDomains(m, projectName) - if d == nil { - return nil - } - return d.Container -} - // ProjectEnv returns the per-project env override, or nil when unset. // Exported because secrets backends (dotenv path resolution, infisical // disabled-flag check) read it directly. @@ -310,41 +114,3 @@ func ProjectDev(m *Manifest, projectName string) string { } return d.Dev.Command } - -// workspaceDeployConfig is the typed view over manifest.domains.deploy.config -// for fields the workspace-level kustomize backend cares about. Returns nil -// when no config blob has been written. Lives here (rather than in a -// per-backend package) because the deploy domain is the only one whose -// workspace-level config has shared fields used by multiple kinds (today -// only kustomize, but s3 deployment may eventually want a workspace-level -// namespace too). -type workspaceDeployConfigShape struct { - Namespace string `json:"namespace,omitempty"` - KustomizationPath string `json:"kustomizationPath,omitempty"` -} - -func workspaceDeployConfig(m *Manifest) *workspaceDeployConfigShape { - if m == nil || m.Domains == nil || m.Domains.Deploy == nil || len(m.Domains.Deploy.Config) == 0 { - return nil - } - var cfg workspaceDeployConfigShape - if err := json.Unmarshal(m.Domains.Deploy.Config, &cfg); err != nil { - return nil - } - return &cfg -} - -type workspaceContainerConfigShape struct { - Platform string `json:"platform,omitempty"` -} - -func workspaceContainerConfig(m *Manifest) *workspaceContainerConfigShape { - if m == nil || m.Domains == nil || m.Domains.Container == nil || len(m.Domains.Container.Config) == 0 { - return nil - } - var cfg workspaceContainerConfigShape - if err := json.Unmarshal(m.Domains.Container.Config, &cfg); err != nil { - return nil - } - return &cfg -} diff --git a/packages/cli/internal/core/workspace/backend_apply.go b/packages/cli/internal/core/workspace/backend_apply.go index 7d3ac170..d8ce65d3 100644 --- a/packages/cli/internal/core/workspace/backend_apply.go +++ b/packages/cli/internal/core/workspace/backend_apply.go @@ -7,9 +7,9 @@ package workspace import ( - "encoding/json" - "fmt" "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // ApplyBackendSelection writes a list of fully-qualified ids @@ -27,11 +27,11 @@ func ApplyBackendSelection(projectRoot string, ids []string) error { for _, raw := range ids { idx := strings.IndexByte(raw, '/') if idx <= 0 || idx == len(raw)-1 { - return fmt.Errorf("invalid id %q: expected /", raw) + return i18n.Errorf("backend.id_invalid", raw) } domain := raw[:idx] if prev, dupe := seen[domain]; dupe && prev != raw { - return fmt.Errorf("two selections for domain %q: %q and %q", domain, prev, raw) + return i18n.Errorf("backend.selection_conflict", domain, prev, raw) } seen[domain] = raw applyDomainSelection(m, domain, raw) @@ -55,135 +55,6 @@ func SetWorkspaceSelection(projectRoot, domain, id string) (previous string, err return previous, nil } -// SetPerProjectSelection writes a per-project scoped selection -// (Projects[name].Domains.Container or Projects[name].Domains.Deploy). -// Returns the previous bare-name backend (empty if unset). domain must be -// "container" or "deploy". -func SetPerProjectSelection(projectRoot, domain, id, projectName string) (previous string, err error) { - m, err := EnsureManifest(projectRoot) - if err != nil { - return "", err - } - idx, err := projectIndex(m, projectName) - if err != nil { - return "", err - } - ensureProjectDomains(&m.Projects[idx]) - switch domain { - case "container": - if m.Projects[idx].Domains.Container != nil { - previous = ContainerBackendDocker - } - if id == "" { - m.Projects[idx].Domains.Container = nil - } else { - // Preserve any existing override fields by leaving the struct - // intact when one already exists; create a fresh empty - // override otherwise. - if m.Projects[idx].Domains.Container == nil { - m.Projects[idx].Domains.Container = &ProjectContainerOverride{} - } - } - case "deploy": - if m.Projects[idx].Domains.Deploy != nil { - previous = m.Projects[idx].Domains.Deploy.Kind - } - if id == "" { - m.Projects[idx].Domains.Deploy = nil - } else { - kind := stripDomainPrefix(id) - if m.Projects[idx].Domains.Deploy == nil { - m.Projects[idx].Domains.Deploy = &ProjectDeployBackend{Kind: kind} - } else { - // Preserve config when re-selecting the same kind; reset - // config when switching kinds. - if m.Projects[idx].Domains.Deploy.Kind != kind { - m.Projects[idx].Domains.Deploy.Config = nil - } - m.Projects[idx].Domains.Deploy.Kind = kind - } - } - default: - return "", fmt.Errorf("domain %q has no per-project scope", domain) - } - pruneProjectDomains(&m.Projects[idx]) - if err := WriteManifest(projectRoot, m); err != nil { - return "", err - } - return previous, nil -} - -// SetProjectContainerNamespace writes the registry namespace into -// projects[name].domains.container.namespace. The container section must -// already exist (caller adds container backend first via -// SetPerProjectSelection); this helper only fills the field. Empty -// namespace clears the field. -func SetProjectContainerNamespace(projectRoot, projectName, namespace string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - idx, err := projectIndex(m, projectName) - if err != nil { - return err - } - ensureProjectContainer(&m.Projects[idx]) - m.Projects[idx].Domains.Container.Namespace = namespace - return WriteManifest(projectRoot, m) -} - -// SetProjectContainerImage writes the fully-resolved image ref used by -// build / push back into the project's container section so deploy -// backends can consume the same image without asking users to edit -// Kubernetes YAML by hand. -func SetProjectContainerImage(projectRoot, projectName, image string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - idx, err := projectIndex(m, projectName) - if err != nil { - return err - } - ensureProjectContainer(&m.Projects[idx]) - m.Projects[idx].Domains.Container.Image = strings.TrimSpace(image) - return WriteManifest(projectRoot, m) -} - -// SetProjectContainerKind writes projects[name].domains.container.kind. -// The container section is created when missing because selecting a kind -// is itself an explicit opt-in to container builds. -func SetProjectContainerKind(projectRoot, projectName, kind string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - idx, err := projectIndex(m, projectName) - if err != nil { - return err - } - ensureProjectContainer(&m.Projects[idx]) - m.Projects[idx].Domains.Container.Kind = strings.TrimSpace(kind) - return WriteManifest(projectRoot, m) -} - -// SetWorkspaceContainerPlatform writes the target Docker image platform -// used by `one container build`, e.g. "linux/amd64". Empty platform clears -// the field. -func SetWorkspaceContainerPlatform(projectRoot, platform string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - ensureWorkspaceContainer(m) - cfg := workspaceContainerConfig(m) - if cfg == nil { - cfg = &workspaceContainerConfigShape{} - } - cfg.Platform = strings.TrimSpace(platform) - return writeWorkspaceContainerConfig(m, projectRoot, cfg) -} - // 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 { @@ -199,90 +70,6 @@ func SetProjectBuildVersion(projectRoot, projectName, version string) error { return WriteManifest(projectRoot, m) } -// SetProjectDeployBucket writes the S3 bucket into -// projects[name].domains.deploy.config.bucket. The deploy section must -// already exist (caller adds deploy backend first via -// SetPerProjectSelection). Empty bucket clears the field. -func SetProjectDeployBucket(projectRoot, projectName, bucket string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - idx, err := projectIndex(m, projectName) - if err != nil { - return err - } - if m.Projects[idx].Domains == nil || m.Projects[idx].Domains.Deploy == nil { - return fmt.Errorf("project %q has no deploy section; set the deploy backend first", projectName) - } - cfg := map[string]json.RawMessage{} - if len(m.Projects[idx].Domains.Deploy.Config) > 0 { - if err := json.Unmarshal(m.Projects[idx].Domains.Deploy.Config, &cfg); err != nil { - return fmt.Errorf("project %q deploy config is malformed: %w", projectName, err) - } - } - bucket = strings.TrimSpace(bucket) - if bucket == "" { - delete(cfg, "bucket") - } else { - raw, err := json.Marshal(bucket) - if err != nil { - return err - } - cfg["bucket"] = raw - } - if len(cfg) == 0 { - m.Projects[idx].Domains.Deploy.Config = nil - } else { - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - m.Projects[idx].Domains.Deploy.Config = raw - } - return WriteManifest(projectRoot, m) -} - -// SetWorkspaceDeployTarget writes workspace-level explicit k8s namespace -// override + kustomize overlay path into manifest.domains.deploy.config. -// Either argument may be empty to clear that single field; empty namespace -// falls back to workspace.id at read time. Empty manifest.domains.deploy -// section is created on first call. -func SetWorkspaceDeployTarget(projectRoot, namespace, kustomizationPath string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - ensureWorkspaceDeploy(m) - cfg := workspaceDeployConfig(m) - if cfg == nil { - cfg = &workspaceDeployConfigShape{} - } - cfg.Namespace = namespace - cfg.KustomizationPath = kustomizationPath - return writeWorkspaceDeployConfig(m, projectRoot, cfg) -} - -// SetWorkspaceDeployK8sTarget writes the k8s deploy target chosen by k8s -// deploy configuration: explicit namespace override + overlay path. -// Empty namespace falls back to workspace.id at read time; empty -// kustomizationPath clears the field. Callers normally pass the canonical -// production overlay. -func SetWorkspaceDeployK8sTarget(projectRoot, namespace, kustomizationPath string) error { - m, err := EnsureManifest(projectRoot) - if err != nil { - return err - } - ensureWorkspaceDeploy(m) - cfg := workspaceDeployConfig(m) - if cfg == nil { - cfg = &workspaceDeployConfigShape{} - } - cfg.Namespace = strings.TrimSpace(namespace) - cfg.KustomizationPath = strings.TrimSpace(kustomizationPath) - return writeWorkspaceDeployConfig(m, projectRoot, cfg) -} - // applyDomainSelection writes the given namespaced id into the appropriate // field on m. Unknown domains are silently ignored — the registry // validation test catches mismatches at build time. CI / Dev are not @@ -345,7 +132,7 @@ func projectIndex(m *Manifest, projectName string) (int, error) { return i, nil } } - return -1, fmt.Errorf("project %q not found in manifest", projectName) + return -1, i18n.Errorf("workspace.project_missing", projectName) } func ensureProjectDomains(p *ManifestProject) { @@ -354,20 +141,13 @@ func ensureProjectDomains(p *ManifestProject) { } } -func ensureProjectContainer(p *ManifestProject) { - ensureProjectDomains(p) - if p.Domains.Container == nil { - p.Domains.Container = &ProjectContainerOverride{} - } -} - // pruneProjectDomains drops the Domains pointer when every override is // empty, keeping JSON output tidy. func pruneProjectDomains(p *ManifestProject) { if p.Domains == nil { return } - if p.Domains.Env == nil && p.Domains.Container == nil && p.Domains.Deploy == nil && p.Domains.Dev == nil { + if p.Domains.Env == nil && p.Domains.Dev == nil { p.Domains = nil } } @@ -380,51 +160,3 @@ func ensureWorkspaceEnv(m *Manifest) { m.Domains.Env = &BackendRef{} } } - -func ensureWorkspaceDeploy(m *Manifest) { - if m.Domains == nil { - m.Domains = &WorkspaceDomains{} - } - if m.Domains.Deploy == nil { - m.Domains.Deploy = &BackendRef{Kind: DeployBackendKustomize} - } -} - -func ensureWorkspaceContainer(m *Manifest) { - if m.Domains == nil { - m.Domains = &WorkspaceDomains{} - } - if m.Domains.Container == nil { - m.Domains.Container = &BackendRef{Kind: ContainerBackendDocker} - } -} - -func writeWorkspaceDeployConfig(m *Manifest, projectRoot string, cfg *workspaceDeployConfigShape) error { - if cfg == nil || (cfg.Namespace == "" && cfg.KustomizationPath == "") { - if m.Domains != nil && m.Domains.Deploy != nil { - m.Domains.Deploy.Config = nil - } - } else { - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - m.Domains.Deploy.Config = raw - } - return WriteManifest(projectRoot, m) -} - -func writeWorkspaceContainerConfig(m *Manifest, projectRoot string, cfg *workspaceContainerConfigShape) error { - if cfg == nil || cfg.Platform == "" { - if m.Domains != nil && m.Domains.Container != nil { - m.Domains.Container.Config = nil - } - } else { - raw, err := json.Marshal(cfg) - if err != nil { - return err - } - m.Domains.Container.Config = raw - } - return WriteManifest(projectRoot, m) -} diff --git a/packages/cli/internal/core/workspace/backend_test.go b/packages/cli/internal/core/workspace/backend_test.go index 26ddeb29..db95f407 100644 --- a/packages/cli/internal/core/workspace/backend_test.go +++ b/packages/cli/internal/core/workspace/backend_test.go @@ -24,321 +24,3 @@ func rawConfig(t *testing.T, v any) json.RawMessage { } return raw } - -func TestContainerNamespaceForProject_ReadsManifest(t *testing.T) { - m := &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{ - { - Name: "api", - RelativeDir: "services/api", - Domains: &ProjectDomains{ - Container: &ProjectContainerOverride{Namespace: "acme-corp"}, - }, - }, - {Name: "lib", RelativeDir: "packages/lib"}, - }, - } - if got := ContainerNamespaceForProject(m, "api"); got != "acme-corp" { - t.Errorf("api: got %q, want acme-corp", got) - } - if got := ContainerNamespaceForProject(m, "lib"); got != "" { - t.Errorf("lib has no container section, got %q", got) - } - if got := ContainerNamespaceForProject(m, "missing"); got != "" { - t.Errorf("missing project should return empty, got %q", got) - } - if got := ContainerNamespaceForProject(nil, "api"); got != "" { - t.Errorf("nil manifest should return empty, got %q", got) - } -} - -func TestContainerKindForProject(t *testing.T) { - m := &Manifest{ - Version: ManifestVersion, - Domains: &WorkspaceDomains{ - Container: &BackendRef{Kind: "dockerhub"}, - }, - Projects: []ManifestProject{ - { - Name: "api", - RelativeDir: "services/api", - Domains: &ProjectDomains{ - Container: &ProjectContainerOverride{Kind: "acr"}, - }, - }, - { - Name: "web", - RelativeDir: "apps/web", - Domains: &ProjectDomains{ - Container: &ProjectContainerOverride{}, // explicit but no Kind - }, - }, - {Name: "lib", RelativeDir: "packages/lib"}, - }, - } - // per-project pin wins over workspace default - if got := ContainerKindForProject(m, "api"); got != "acr" { - t.Errorf("api: got %q, want acr", got) - } - // empty per-project Kind falls back to workspace-level - if got := ContainerKindForProject(m, "web"); got != "dockerhub" { - t.Errorf("web: got %q, want dockerhub", got) - } - // no container section at all → workspace-level (still dockerhub) - if got := ContainerKindForProject(m, "lib"); got != "dockerhub" { - t.Errorf("lib: got %q, want dockerhub", got) - } - // nil manifest falls back to "docker" - if got := ContainerKindForProject(nil, "api"); got != ContainerBackendDocker { - t.Errorf("nil manifest: got %q, want %q", got, ContainerBackendDocker) - } - // manifest with no workspace-level container falls back to "docker" - empty := &Manifest{Version: ManifestVersion} - if got := ContainerKindForProject(empty, "api"); got != ContainerBackendDocker { - t.Errorf("empty manifest: got %q, want %q", got, ContainerBackendDocker) - } -} - -func TestDeployBucketForProject_ReadsManifest(t *testing.T) { - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ - ID: "demo-abc123", - Name: "demo", - }, - Projects: []ManifestProject{ - { - Name: "web", - RelativeDir: "apps/web", - Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{ - Kind: "aws-s3", - Config: rawConfig(t, map[string]string{"bucket": "web-prod"}), - }, - }, - }, - { - Name: "api", - RelativeDir: "services/api", - Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{Kind: "kustomize"}, - }, - }, - { - Name: "docs", - RelativeDir: "apps/docs", - Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{Kind: "aws-s3"}, - }, - }, - }, - } - if got := DeployBucketForProject(m, "web"); got != "web-prod" { - t.Errorf("web: got %q, want web-prod", got) - } - if got := ExplicitDeployBucketForProject(m, "web"); got != "web-prod" { - t.Errorf("web explicit: got %q, want web-prod", got) - } - if got := DeployBucketForProject(m, "docs"); got != "demo-abc123" { - t.Errorf("docs fallback: got %q, want demo-abc123", got) - } - if got := ExplicitDeployBucketForProject(m, "docs"); got != "" { - t.Errorf("docs explicit bucket should be empty, got %q", got) - } - // kustomize project: bucket is unset, helper returns empty. - if got := DeployBucketForProject(m, "api"); got != "" { - t.Errorf("api (kustomize): got %q, want empty", got) - } - if got := DeployBucketForProject(m, "missing"); got != "" { - t.Errorf("missing: got %q, want empty", got) - } -} - -func TestDeployNamespaceAndPath_ReadsManifest(t *testing.T) { - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ - ID: "demo-abc123", - Name: "demo", - }, - Domains: &WorkspaceDomains{ - Deploy: &BackendRef{ - Kind: "kustomize", - Config: rawConfig(t, workspaceDeployConfigShape{ - Namespace: "default", - KustomizationPath: "k8s/overlays/prod", - }), - }, - }, - } - if got := DeployNamespace(m); got != "default" { - t.Errorf("namespace: got %q, want default", got) - } - if got := DeployKustomizationPath(m); got != "k8s/overlays/prod" { - t.Errorf("path: got %q, want k8s/overlays/prod", got) - } - if got := DeployNamespace(&Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ - ID: "demo-abc123", - Name: "demo", - }, - }); got != "demo-abc123" { - t.Errorf("missing deploy namespace: got %q, want workspace id", got) - } - if got := DeployNamespace(&Manifest{Version: ManifestVersion}); got != "" { - t.Errorf("missing deploy namespace and workspace id: got %q, want empty", got) - } -} - -func TestSetProjectContainerNamespace_PreservesOtherFields(t *testing.T) { - tmp := t.TempDir() - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{{ - Name: "api", - RelativeDir: "services/api", - TemplateID: "nestjs-api", - Toolchain: "node", - Domains: &ProjectDomains{ - Container: &ProjectContainerOverride{ - Kind: "acr", - Image: "myorg/api:custom", - }, - }, - }}, - }); err != nil { - t.Fatal(err) - } - if err := SetProjectContainerNamespace(tmp, "api", "acme-corp"); err != nil { - t.Fatalf("SetProjectContainerNamespace: %v", err) - } - got, _ := ReadManifest(tmp) - c := got.Projects[0].Domains.Container - if c == nil || c.Namespace != "acme-corp" { - t.Errorf("namespace not written: %+v", c) - } - if c.Image != "myorg/api:custom" || c.Kind != "acr" { - t.Errorf("existing container fields lost: %+v", c) - } -} - -func TestSetProjectContainerNamespace_CreatesSectionIfMissing(t *testing.T) { - tmp := t.TempDir() - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{{ - Name: "api", - RelativeDir: "services/api", - TemplateID: "nestjs-api", - Toolchain: "node", - }}, - }); err != nil { - t.Fatal(err) - } - if err := SetProjectContainerNamespace(tmp, "api", "acme-corp"); err != nil { - t.Fatalf("SetProjectContainerNamespace: %v", err) - } - got, _ := ReadManifest(tmp) - if got.Projects[0].Domains == nil || got.Projects[0].Domains.Container == nil { - t.Fatalf("container section not created") - } - if got.Projects[0].Domains.Container.Namespace != "acme-corp" { - t.Errorf("namespace not written: %+v", got.Projects[0].Domains.Container) - } -} - -func TestSetProjectDeployBucket_RequiresExistingDeploySection(t *testing.T) { - tmp := t.TempDir() - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{{ - Name: "web", - RelativeDir: "apps/web", - TemplateID: "web-vite", - Toolchain: "node", - }}, - }); err != nil { - t.Fatal(err) - } - // No deploy section yet -> bucket setter should refuse rather than - // silently create an empty deploy block. - if err := SetProjectDeployBucket(tmp, "web", "web-prod"); err == nil { - t.Errorf("expected error when deploy section is missing") - } -} - -func TestSetProjectDeployBucket_PreservesOtherFields(t *testing.T) { - tmp := t.TempDir() - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{{ - Name: "web", - RelativeDir: "apps/web", - TemplateID: "web-vite", - Toolchain: "node", - Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{ - Kind: "aws-s3", - }, - }, - }}, - }); err != nil { - t.Fatal(err) - } - if err := SetProjectDeployBucket(tmp, "web", "web-prod"); err != nil { - t.Fatalf("SetProjectDeployBucket: %v", err) - } - got, _ := ReadManifest(tmp) - d := got.Projects[0].Domains.Deploy - if d.Kind != "aws-s3" { - t.Errorf("existing deploy fields lost: %+v", d) - } - if got := ExplicitDeployBucketForProject(got, "web"); got != "web-prod" { - t.Errorf("bucket not written: %+v", d) - } -} - -func TestSetWorkspaceDeployTarget_CreatesSection(t *testing.T) { - tmp := t.TempDir() - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{}, - }); err != nil { - t.Fatal(err) - } - if err := SetWorkspaceDeployTarget(tmp, "default", "kustomize/overlays/prod"); err != nil { - t.Fatalf("SetWorkspaceDeployTarget: %v", err) - } - got, _ := ReadManifest(tmp) - if got.Domains == nil || got.Domains.Deploy == nil { - t.Fatalf("deploy section not created") - } - if ns := DeployNamespace(got); ns != "default" { - t.Errorf("namespace = %q, want default", ns) - } - if path := DeployKustomizationPath(got); path != "kustomize/overlays/prod" { - t.Errorf("path = %q", path) - } -} - -func TestSetWorkspaceDeployTarget_PreservesKind(t *testing.T) { - tmp := t.TempDir() - if err := WriteManifest(tmp, &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{}, - Domains: &WorkspaceDomains{ - Deploy: &BackendRef{Kind: "kustomize"}, - }, - }); err != nil { - t.Fatal(err) - } - if err := SetWorkspaceDeployTarget(tmp, "staging", "k8s/overlays/staging"); err != nil { - t.Fatalf("SetWorkspaceDeployTarget: %v", err) - } - got, _ := ReadManifest(tmp) - if got.Domains == nil || got.Domains.Deploy == nil || got.Domains.Deploy.Kind != "kustomize" { - t.Errorf("deploy kind lost: %+v", got.Domains) - } -} diff --git a/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go b/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go index aa577bcc..ce743c1c 100644 --- a/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go +++ b/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go @@ -43,16 +43,4 @@ func TestDashboardDevelopmentFixture(t *testing.T) { t.Fatalf("project %q is missing from fixture", name) } } - if got := ContainerKindForProject(manifest, "web"); got != "docker" { - t.Fatalf("web container backend = %q, want docker", got) - } - if got := DeployForProject(manifest, "web").Backend; got != "vercel" { - t.Fatalf("web deploy backend = %q, want vercel", got) - } - if got := DeployForProject(manifest, "api").Backend; got != "kustomize" { - t.Fatalf("api deploy backend = %q, want kustomize", got) - } - if got := DeployForProject(manifest, "docs").Backend; got != "aws-s3" { - t.Fatalf("docs deploy backend = %q, want aws-s3", got) - } } diff --git a/packages/cli/internal/core/workspace/infra.go b/packages/cli/internal/core/workspace/infra.go index 70022b0a..015acfee 100644 --- a/packages/cli/internal/core/workspace/infra.go +++ b/packages/cli/internal/core/workspace/infra.go @@ -9,96 +9,6 @@ import ( "strings" ) -// HasComposeService reports whether docker-compose.yml at projectRoot -// declares a service named workloadName. Matches the canonical indented -// YAML key — no deep parse. -func HasComposeService(projectRoot, workloadName string) bool { - composePath := filepath.Join(projectRoot, "docker-compose.yml") - raw, err := os.ReadFile(composePath) - if err != nil { - return false - } - content := normalizeNewlines(string(raw)) - pattern := regexp.MustCompile(`(?m)^\s{2}` + regexp.QuoteMeta(workloadName) + `:\s*$`) - return pattern.MatchString(content) -} - -// HasK8sWorkload reports whether k8s/deployment.yaml at projectRoot contains -// a metadata.name pointing at workloadName. -func HasK8sWorkload(projectRoot, workloadName string) bool { - deploymentPath := filepath.Join(projectRoot, "k8s", "deployment.yaml") - raw, err := os.ReadFile(deploymentPath) - if err != nil { - return false - } - content := normalizeNewlines(string(raw)) - pattern := regexp.MustCompile(`metadata:\n\s+name:\s+` + regexp.QuoteMeta(workloadName) + `\b`) - return pattern.MatchString(content) -} - -// HasComposeFile reports whether docker-compose.yml exists at the project -// root (used to derive coverage.docker_compose.enabled). -func HasComposeFile(projectRoot string) bool { - _, err := os.Stat(filepath.Join(projectRoot, "docker-compose.yml")) - return err == nil -} - -// HasK8sFile reports whether k8s/deployment.yaml exists. -func HasK8sFile(projectRoot string) bool { - _, err := os.Stat(filepath.Join(projectRoot, "k8s", "deployment.yaml")) - return err == nil -} - -// HasKustomizeDir reports whether the workspace has a generated kustomize -// tree. This is separate from HasK8sFile: v0.5+ deploy/kustomize writes under -// kustomize/, while the older raw k8s path wrote k8s/deployment.yaml. -func HasKustomizeDir(projectRoot string) bool { - st, err := os.Stat(filepath.Join(projectRoot, "kustomize")) - return err == nil && st.IsDir() -} - -// ExpectedKustomizeFiles returns the deploy/kustomize artifact set for one -// workload, relative to the workspace root. -func ExpectedKustomizeFiles(workloadName string) []string { - return []string{ - "kustomize/base/" + workloadName + ".yaml", - "kustomize/base/kustomization.yaml", - "kustomize/overlays/dev/kustomization.yaml", - "kustomize/overlays/prod/kustomization.yaml", - } -} - -// MissingKustomizeFiles returns the deploy/kustomize artifact paths that do -// not exist yet, relative to the workspace root. -func MissingKustomizeFiles(projectRoot, workloadName string) []string { - missing := []string{} - for _, rel := range ExpectedKustomizeFiles(workloadName) { - if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(rel))); err != nil { - missing = append(missing, rel) - } - } - return missing -} - -// HasDockerfile is a thin wrapper for the per-project Dockerfile check. -func HasDockerfile(targetDir string) bool { - _, err := os.Stat(filepath.Join(targetDir, "Dockerfile")) - return err == nil -} - -// ResolveWorkloadName prefers kebab-case of the project name, falling -// back to kebab-case of the target dir's basename. -func ResolveWorkloadName(projectName, targetDir string) string { - if k := ToKebabCase(projectName); k != "" { - return k - } - return ToKebabCase(filepath.Base(targetDir)) -} - -func normalizeNewlines(s string) string { - return strings.ReplaceAll(s, "\r\n", "\n") -} - // 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. diff --git a/packages/cli/internal/core/workspace/json_order.go b/packages/cli/internal/core/workspace/json_order.go index b78cd0d1..467ceacc 100644 --- a/packages/cli/internal/core/workspace/json_order.go +++ b/packages/cli/internal/core/workspace/json_order.go @@ -5,6 +5,8 @@ import ( "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 @@ -21,7 +23,7 @@ func parseScriptKeysInOrder(raw []byte) ([]string, error) { return nil, err } if delim, ok := tok.(json.Delim); !ok || delim != '{' { - return nil, errors.New("package.json root is not an object") + return nil, errors.New(i18n.T("workspace.package_object")) } for dec.More() { keyTok, err := dec.Token() @@ -30,7 +32,7 @@ func parseScriptKeysInOrder(raw []byte) ([]string, error) { } key, ok := keyTok.(string) if !ok { - return nil, errors.New("non-string key in package.json") + return nil, errors.New(i18n.T("workspace.package_key")) } if key != "scripts" { if err := skipValue(dec); err != nil { @@ -55,7 +57,7 @@ func parseScriptKeysInOrder(raw []byte) ([]string, error) { } scriptKey, ok := scriptKeyTok.(string) if !ok { - return nil, errors.New("non-string key inside scripts") + return nil, errors.New(i18n.T("workspace.script_key")) } out = append(out, scriptKey) if err := skipValue(dec); err != nil { diff --git a/packages/cli/internal/core/workspace/manifest.go b/packages/cli/internal/core/workspace/manifest.go index 6ad29778..7673e88f 100644 --- a/packages/cli/internal/core/workspace/manifest.go +++ b/packages/cli/internal/core/workspace/manifest.go @@ -11,6 +11,7 @@ import ( 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" ) // ManifestFilename is the on-disk location of the workspace manifest at the @@ -27,19 +28,11 @@ const ManifestVersion = 1 // // Current layout: // - workspace: identity only (id, name) -// - environments: top-level environment-name list + default name; consumed -// by secrets backends, deploy --env validation, and per-project -// deploy.config.env validation alike -// - domains: workspace-level backend selections, keyed by domain name -// ("env", "deploy", "container"). Each value carries kind + -// a kind-specific config blob (json.RawMessage, decoded by callers via -// typed accessors). +// - environments: environment-name list and default for secrets backends +// - domains: workspace environment backend and its config // - projects[]: each project carries identity (name, relativeDir, // templateId, toolchain, buildVersion, packageManager) plus an optional -// domains override block. Project-scope env / container override carry -// no kind (always inherited from workspace); project-scope deploy -// carries full kind+config because deploy is genuinely -// per-project polymorphic. +// domains block with environment overrides and a development command. type Manifest struct { Version int `json:"version"` Workspace *ManifestWorkspace `json:"workspace,omitempty"` @@ -57,13 +50,8 @@ type ManifestWorkspace struct { Name string `json:"name"` } -// Environments is the workspace-level environment-name registry. Names are -// the deployment target names ("dev" / "preview" / "prod" by default), used -// in three independent places: -// - secrets backends enumerate `Names` to know which env files / Infisical -// environments exist -// - `one deploy --env ` validates against `Names` -// - projects[].domains.deploy.config.env validates against `Names` +// Environments names the dotenv files or Infisical environments available +// to the workspace ("dev" / "preview" / "prod" by default). // // Default is the env name used when --env is omitted; it must appear in // Names. New workspaces seed `["dev","preview","prod"]` with default "dev". @@ -78,20 +66,14 @@ type Environments struct { // independent of any specific secrets backend. var DefaultEnvironments = []string{"dev", "preview", "prod"} -// WorkspaceDomains is the workspace-level backend selection block. Each -// field is optional and represents the selected backend for that domain. -// Marshalled as {"env": {...}, "deploy": {...}, "container": {...}} so the -// JSON shape mirrors per-project ProjectDomains. +// WorkspaceDomains selects the optional workspace environment backend. type WorkspaceDomains struct { - Env *BackendRef `json:"env,omitempty"` - Deploy *BackendRef `json:"deploy,omitempty"` - Container *BackendRef `json:"container,omitempty"` + Env *BackendRef `json:"env,omitempty"` } // BackendRef is the workspace-level "selected backend" for a single domain. -// `Kind` is the bare backend name (e.g. "infisical", "kustomize", "docker"). -// `Config` is a kind-specific JSON blob; callers decode via typed accessors per domain -// (see internal/core/workspace/domains/{env,deploy,container}.go). +// `Kind` is the bare backend name ("infisical" or "dotenv"). +// `Config` is decoded via the environment backend's typed accessors. type BackendRef struct { Kind string `json:"kind,omitempty"` Config json.RawMessage `json:"config,omitempty"` @@ -110,20 +92,11 @@ type ManifestProject struct { Domains *ProjectDomains `json:"domains,omitempty"` } -// ProjectDomains is the per-project override block. Keys mirror -// WorkspaceDomains. The shape of each value differs by domain because the -// scopes carry different state: -// - env: an override (path / inherits / disabled / keys); kind is -// always inherited from workspace -// - container: an override (image / namespace / optional kind); all current -// kinds share the compiled Docker/OCI execution module -// - deploy: a full BackendRef (kind + config) because the -// workspace may host one project on Vercel and another on kustomize +// ProjectDomains holds environment overrides and the development command. +// The environment backend is always inherited from the workspace. type ProjectDomains struct { - Env *ProjectEnvOverride `json:"env,omitempty"` - Container *ProjectContainerOverride `json:"container,omitempty"` - Deploy *ProjectDeployBackend `json:"deploy,omitempty"` - Dev *ProjectDevOverride `json:"dev,omitempty"` + Env *ProjectEnvOverride `json:"env,omitempty"` + Dev *ProjectDevOverride `json:"dev,omitempty"` } // ProjectDevOverride is the per-project dev command for `one dev`. @@ -155,42 +128,6 @@ type ProjectEnvOverride struct { Keys []string `json:"keys,omitempty"` } -// ProjectContainerOverride marks a project as having an owned Dockerfile -// (presence of the section means "build this project with `one container -// build`") and carries optional per-project image / registry overrides. -type ProjectContainerOverride struct { - // Kind selects the container backend implementation. Empty means - // "docker" (generic Docker registry protocol). Other recognised - // values: "dockerhub" / "ghcr" / "acr" (Aliyun ACR). Mirrors - // ProjectDeployBackend.Kind. The resolver falls back to the - // workspace-level manifest.domains.container.kind, then to - // "docker". - Kind string `json:"kind,omitempty"` - - // Image records or overrides the - // `/[/]:` tag used by - // `one container build` / `one deploy`. Optional. - Image string `json:"image,omitempty"` - - // Namespace is the registry namespace (org / team prefix). Lives - // per-project because the same registry credential frequently hosts - // multiple workloads under different namespaces. Empty means "use the - // container profile default namespace". - Namespace string `json:"namespace,omitempty"` -} - -// ProjectDeployBackend is the per-project deploy backend selection. -// Mirrors BackendRef shape because deploy is the only domain where -// projects in the same workspace genuinely choose different -// implementations (web → s3, api → kustomize). `Config` carries -// kind-specific fields (e.g. the Vercel projectId, the S3 bucket, the -// per-deploy env name); decoded via accessors in -// internal/core/workspace/domains/deploy/. -type ProjectDeployBackend struct { - Kind string `json:"kind,omitempty"` - Config json.RawMessage `json:"config,omitempty"` -} - // ManifestPath returns the absolute path to one.manifest.json under // projectRoot. func ManifestPath(projectRoot string) string { @@ -257,36 +194,24 @@ func ReadManifestSnapshot(projectRoot string) (*Manifest, string, error) { if errors.Is(err, os.ErrNotExist) { return emptyManifest(), "", nil } - return nil, "", cliErrors.New(cliErrors.MANIFEST_INVALID, "one.manifest.json 解析失败。") + return nil, "", cliErrors.New(cliErrors.MANIFEST_INVALID, i18n.T("manifest.parse_failed")) } var m Manifest dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&m); err != nil { - return nil, "", cliErrors.New(cliErrors.MANIFEST_INVALID, "one.manifest.json 解析失败。") + if err.Error() == `json: unknown field "deploy"` || err.Error() == `json: unknown field "container"` { + return nil, "", cliErrors.New(cliErrors.MANIFEST_INVALID, i18n.T("manifest.retired_fields")) + } + return nil, "", cliErrors.New(cliErrors.MANIFEST_INVALID, i18n.T("manifest.parse_failed")) } if m.Version != ManifestVersion { - msg := fmt.Sprintf("one.manifest.json 版本 %d 不支持,当前 CLI 仅认 v%d。", m.Version, ManifestVersion) + msg := i18n.Tf("manifest.version_unsupported", m.Version, ManifestVersion) if m.Version > ManifestVersion { - msg += " 请升级 one CLI,或按当前 manifest schema 手动迁移后再重试。" + msg += i18n.T("manifest.upgrade_hint") } if m.Version == 0 { - msg += " 旧 manifest 需要手动迁移:" + - "(1) 顶层 env / deploy / container 三个 section 合并到 domains: " + - "`env.backend → domains.env.kind`," + - "`env.{projectId,projectName,rootPath,keys} → domains.env.config.{...}`;" + - "`deploy.{namespace,kustomizationPath} → domains.deploy.config.{...}`;" + - "`container.platform → domains.container.config.platform`;" + - "`preferredProfile` 不进 manifest,改写 ~/.config/one/config.json#workspaces。" + - "(2) 顶层 env.environments / env.defaultEnv 提到顶层 environments: " + - "`env.environments → environments.names`,`env.defaultEnv → environments.default`。" + - "(3) 每个 project 的 env/container/deploy 包到 domains 下: " + - "`projects[].env → projects[].domains.env`,`projects[].container → projects[].domains.container`," + - "`projects[].deploy.target → projects[].domains.deploy.kind`," + - "`projects[].deploy.{vercel,cloudflare,edgeone,kustomize}.* → projects[].domains.deploy.config.*`。" + - "(4) 删除字段:ci / dev(不再由 manifest 控制)、ai(默认全启用所有 provider)、" + - "顶层 packageManager、workspace.roots、environments(旧的 dead map)、所有 profile 字段。" + - "(5) 顶层 \"version\" 字段改为 1。" + msg += i18n.T("manifest.migration_hint") } return nil, "", cliErrors.New(cliErrors.MANIFEST_INVALID, msg) } diff --git a/packages/cli/internal/core/workspace/node_members.go b/packages/cli/internal/core/workspace/node_members.go new file mode 100644 index 00000000..2ade034c --- /dev/null +++ b/packages/cli/internal/core/workspace/node_members.go @@ -0,0 +1,140 @@ +package workspace + +import ( + "encoding/json" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +// NodeProjectPackageDirs returns a logical project's package and the members +// declared in its package.json workspaces. Paths are relative to the One root. +// A composite project stays one manifest entry while its packages share the +// root package manager and dependency cache. Read can be a FilePlan overlay. +func NodeProjectPackageDirs(root, dir string, read func(string) ([]byte, error)) ([]string, error) { + if read == nil { + read = func(name string) ([]byte, error) { + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) + if os.IsNotExist(err) { + return nil, nil + } + return b, err + } + } + seen := map[string]bool{} + var visit func(string) error + visit = func(dir string) error { + dir = filepath.ToSlash(filepath.Clean(dir)) + if seen[dir] { + return nil + } + if err := fsutil.SafeWritePath(root, filepath.Join(root, dir, "package.json")); err != nil { + return err + } + seen[dir] = true + raw, err := read(path.Join(dir, "package.json")) + if err != nil || raw == nil { + return err + } + var pkg struct { + Workspaces json.RawMessage `json:"workspaces"` + } + if err := json.Unmarshal(raw, &pkg); err != nil { + return err + } + if len(pkg.Workspaces) == 0 { + return nil + } + var patterns []string + if err := json.Unmarshal(pkg.Workspaces, &patterns); err != nil { + var object struct { + Packages []string `json:"packages"` + } + if err := json.Unmarshal(pkg.Workspaces, &object); err != nil { + return i18n.Errorf("creation.workspaces_invalid") + } + patterns = object.Packages + } + members := map[string]bool{} + var exclusions []string + for _, pattern := range patterns { + negative := strings.HasPrefix(pattern, "!") + pattern = strings.TrimPrefix(strings.TrimPrefix(pattern, "!"), "./") + // Keep discovery bounded to this project. Complex glob syntax is + // rejected rather than silently registering only some members. + if pattern == "" || path.IsAbs(pattern) || strings.Contains(pattern, "\\") || strings.ContainsAny(pattern, "{}()") || strings.Contains(pattern, "**") { + return i18n.Errorf("workspace.node_member_pattern", dir, pattern) + } + for _, segment := range strings.Split(pattern, "/") { + if segment == ".." || segment == "node_modules" || segment == ".git" { + return i18n.Errorf("workspace.node_member_pattern", dir, pattern) + } + } + if _, err := path.Match(pattern, ""); err != nil { + return i18n.Errorf("workspace.node_member_pattern", dir, pattern) + } + if negative { + exclusions = append(exclusions, pattern) + continue + } + // Validate the literal prefix before Glob can traverse a symlink. + prefix := strings.Split(pattern, "/") + safe := dir + for _, segment := range prefix { + if strings.ContainsAny(segment, "*?[") { + break + } + safe = path.Join(safe, segment) + } + if err := fsutil.SafeWritePath(root, filepath.Join(root, safe, "package.json")); err != nil { + return err + } + matches, err := filepath.Glob(filepath.Join(root, dir, filepath.FromSlash(pattern), "package.json")) + if err != nil { + return err + } + for _, match := range matches { + if err := fsutil.SafeWritePath(root, match); err != nil { + return err + } + rel, err := filepath.Rel(filepath.Join(root, dir), filepath.Dir(match)) + if err != nil { + return err + } + members[filepath.ToSlash(rel)] = true + } + } + var sorted []string + for member := range members { + excluded := false + for _, pattern := range exclusions { + match, _ := path.Match(pattern, member) + excluded = excluded || match + } + if !excluded { + sorted = append(sorted, member) + } + } + sort.Strings(sorted) + for _, member := range sorted { + if err := visit(path.Join(dir, member)); err != nil { + return err + } + } + return nil + } + if err := visit(dir); err != nil { + return nil, err + } + dirs := make([]string, 0, len(seen)) + for dir := range seen { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + return dirs, nil +} diff --git a/packages/cli/internal/core/workspace/node_members_test.go b/packages/cli/internal/core/workspace/node_members_test.go new file mode 100644 index 00000000..dd77d6d6 --- /dev/null +++ b/packages/cli/internal/core/workspace/node_members_test.go @@ -0,0 +1,70 @@ +package workspace + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func writeNodeMember(t *testing.T, root, rel, content string) { + t.Helper() + file := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestNodeMembersRespectDeclarationsAndExclusions(t *testing.T) { + root := t.TempDir() + writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":{"packages":["apps/*","packages/*","!apps/unused"]}}`) + for _, dir := range []string{"apps/ui", "apps/main", "apps/unused", "packages/preload", "fixtures/ignored", "node_modules/ignored"} { + writeNodeMember(t, root, "apps/desktop/"+dir+"/package.json", `{}`) + } + dirs, err := NodeProjectPackageDirs(root, "apps/desktop", nil) + if err != nil { + t.Fatal(err) + } + want := []string{"apps/desktop", "apps/desktop/apps/main", "apps/desktop/apps/ui", "apps/desktop/packages/preload"} + if !reflect.DeepEqual(dirs, want) { + t.Fatalf("members=%v", dirs) + } +} + +func TestNodeMembersRejectEscapesAndSymlinks(t *testing.T) { + root := t.TempDir() + for _, pattern := range []string{"../outside", "/tmp/outside", "apps/../../outside", "node_modules/*", "apps/**", "[invalid"} { + t.Run(pattern, func(t *testing.T) { + writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":["`+pattern+`"]}`) + if _, err := NodeProjectPackageDirs(root, "apps/desktop", nil); err == nil { + t.Fatal("invalid pattern accepted") + } + }) + } + outside := t.TempDir() + writeNodeMember(t, outside, "package.json", `{}`) + writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":["linked"]}`) + if err := os.Symlink(outside, filepath.Join(root, "apps/desktop/linked")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := NodeProjectPackageDirs(root, "apps/desktop", nil); err == nil { + t.Fatal("external symlink accepted") + } +} + +func TestCompositeDependencyStatusIncludesInternalPackages(t *testing.T) { + root := t.TempDir() + writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":["apps/ui"]}`) + writeNodeMember(t, root, "apps/desktop/apps/ui/package.json", `{"dependencies":{"renderer":"1.0.0"}}`) + project := filepath.Join(root, "apps/desktop") + if ProjectDependenciesInstalled(root, project, "node") { + t.Fatal("missing internal dependencies reported ready") + } + writeNodeMember(t, root, "apps/desktop/apps/ui/node_modules/renderer/package.json", `{}`) + if !ProjectDependenciesInstalled(root, project, "node") { + t.Fatal("installed internal dependency reported missing") + } +} diff --git a/packages/cli/internal/core/workspace/overview.go b/packages/cli/internal/core/workspace/overview.go index c6126999..e2cd6c9d 100644 --- a/packages/cli/internal/core/workspace/overview.go +++ b/packages/cli/internal/core/workspace/overview.go @@ -8,14 +8,8 @@ package workspace // in". import ( - "context" - "errors" "sort" "strings" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" ) // OverviewSchema is the JSON envelope version stamp. @@ -37,14 +31,11 @@ const ( // skips it silently). Flagging that as a missing-config issue would be a // false positive. const ( - IssueDomainContainer = "container" - IssueDomainDeploy = "deploy" - IssueDomainEnv = "env" + IssueDomainEnv = "env" IssueSeverityMissing = "missing" IssueReasonBackend = "backend" - IssueReasonProfile = "profile" ) // Overview is the response shape for GET /api/workspace/overview. Present is @@ -78,14 +69,13 @@ type OverviewWorkspace struct { // default merged with per-project overrides) so the UI can render the // effective state without duplicating selector logic. type OverviewProject struct { - Name string `json:"name"` - RelativeDir string `json:"relativeDir"` - Kind string `json:"kind"` - TemplateID string `json:"templateId,omitempty"` - Toolchain string `json:"toolchain,omitempty"` - CompatibleDeployTargets []string `json:"compatibleDeployTargets,omitempty"` - Domains map[string]string `json:"domains,omitempty"` - Issues []OverviewIssue `json:"issues,omitempty"` + Name string `json:"name"` + RelativeDir string `json:"relativeDir"` + Kind string `json:"kind"` + TemplateID string `json:"templateId,omitempty"` + Toolchain string `json:"toolchain,omitempty"` + Domains map[string]string `json:"domains,omitempty"` + Issues []OverviewIssue `json:"issues,omitempty"` } // OverviewIssue is one "missing configuration" finding. Message is a short @@ -98,7 +88,6 @@ type OverviewIssue struct { Reason string `json:"reason,omitempty"` Backend string `json:"backend,omitempty"` Section string `json:"section,omitempty"` - Profile string `json:"profile,omitempty"` } // BuildOverview reads the manifest at root and produces the Overview @@ -120,15 +109,6 @@ func BuildOverview(root string, environments ...string) (Overview, error) { if m == nil || !HasManifest(root) { return Overview{Schema: OverviewSchema, Present: false}, nil } - profileEnvironment := ProfileBindingEnvironment(m, environment) - profiles, _, err := profile.Load() - if err != nil { - return Overview{Schema: OverviewSchema, Present: false}, err - } - registry, err := template.Fetch(context.Background(), "") - if err != nil { - return Overview{Schema: OverviewSchema, Present: false}, err - } ov := Overview{ Schema: OverviewSchema, @@ -146,12 +126,10 @@ func BuildOverview(root string, environments ...string) (Overview, error) { Message: "workspace env backend is not selected", Reason: IssueReasonBackend, }) - } else if issue := profileIssue(profiles, m, root, profileEnvironment, IssueDomainEnv, m.Domains.Env.Kind, ""); issue != nil { - ov.Issues = append(ov.Issues, *issue) } for i := range m.Projects { - ov.Projects = append(ov.Projects, buildProject(root, profileEnvironment, m, profiles, registry, &m.Projects[i])) + ov.Projects = append(ov.Projects, buildProject(m, &m.Projects[i])) } return ov, nil } @@ -173,12 +151,6 @@ func buildWorkspaceSummary(m *Manifest) *OverviewWorkspace { if m.Domains.Env != nil && m.Domains.Env.Kind != "" { domains[IssueDomainEnv] = m.Domains.Env.Kind } - if m.Domains.Deploy != nil && m.Domains.Deploy.Kind != "" { - domains[IssueDomainDeploy] = m.Domains.Deploy.Kind - } - if m.Domains.Container != nil && m.Domains.Container.Kind != "" { - domains[IssueDomainContainer] = m.Domains.Container.Kind - } if len(domains) > 0 { s.Domains = domains } @@ -186,138 +158,20 @@ func buildWorkspaceSummary(m *Manifest) *OverviewWorkspace { return s } -func buildProject( - root, environment string, - m *Manifest, - profiles *profile.Config, - registry *template.Registry, - p *ManifestProject, -) OverviewProject { +func buildProject(m *Manifest, p *ManifestProject) OverviewProject { kind := projectKindFromDir(p.RelativeDir) out := OverviewProject{ - Name: p.Name, - RelativeDir: p.RelativeDir, - Kind: kind, - TemplateID: p.TemplateID, - Toolchain: p.Toolchain, - CompatibleDeployTargets: compatibleDeployTargets(registry, p.TemplateID), - Domains: projectResolvedDomains(m, p), - } - - // packages aren't expected to deploy / build containers / have a dev - // command, so we suppress those three checks for them. - if kind == ProjectKindPackage { - return out - } - - if projectNeedsContainer(m, p) { - hasContainerWorkspaceDefault := m.Domains != nil && m.Domains.Container != nil - if enabled, _ := ContainerForProject(m, p.Name); !enabled && !hasContainerWorkspaceDefault { - out.Issues = append(out.Issues, OverviewIssue{ - Domain: IssueDomainContainer, - Severity: IssueSeverityMissing, - Message: "no container backend selected for this project", - Reason: IssueReasonBackend, - }) - } - } - if backend := out.Domains[IssueDomainContainer]; backend != "" { - if issue := profileIssue(profiles, m, root, environment, IssueDomainContainer, backend, p.Name); issue != nil { - out.Issues = append(out.Issues, *issue) - } - } - - if len(out.CompatibleDeployTargets) > 0 { - hasDeployWorkspaceDefault := m.Domains != nil && m.Domains.Deploy != nil && m.Domains.Deploy.Kind != "" - if DeployForProject(m, p.Name).Backend == "" && !hasDeployWorkspaceDefault { - out.Issues = append(out.Issues, OverviewIssue{ - Domain: IssueDomainDeploy, - Severity: IssueSeverityMissing, - Message: "no deploy backend selected for this project", - Reason: IssueReasonBackend, - }) - } - if backend := out.Domains[IssueDomainDeploy]; backend != "" { - if issue := profileIssue(profiles, m, root, environment, IssueDomainDeploy, backend, p.Name); issue != nil { - out.Issues = append(out.Issues, *issue) - } - } + Name: p.Name, + RelativeDir: p.RelativeDir, + Kind: kind, + TemplateID: p.TemplateID, + Toolchain: p.Toolchain, + Domains: projectResolvedDomains(m, p), } return out } -func compatibleDeployTargets(registry *template.Registry, templateID string) []string { - if registry == nil { - return nil - } - for _, entry := range registry.Templates { - if entry.ID == templateID { - return append([]string(nil), entry.Compat[IssueDomainDeploy]...) - } - } - return nil -} - -func profileIssue( - cfg *profile.Config, - m *Manifest, - root, environment, domain, backend, projectName string, -) *OverviewIssue { - if cfg == nil || backend == "" { - return nil - } - if backend == EnvBackendDotenv || backend == DeployBackendEdgeOne { - return nil - } - section := profile.SectionKey(profile.Domain(domain), backend) - resolved, err := profile.Resolve(profile.ResolveInput{ - Domain: profile.Domain(domain), - Backend: backend, - WorkspaceID: manifestWorkspaceID(m), - WorkspaceRoot: root, - Environment: environment, - ProjectName: projectName, - }) - if err != nil { - requestedProfile := profileNameFromResolveError(err) - return &OverviewIssue{ - Domain: domain, - Severity: IssueSeverityMissing, - Reason: IssueReasonProfile, - Backend: backend, - Section: section, - Profile: requestedProfile, - Message: "no credential profile configured for " + section, - } - } - if !profileComplete(backend, resolved.Profile) { - return &OverviewIssue{ - Domain: domain, - Severity: IssueSeverityMissing, - Reason: IssueReasonProfile, - Backend: backend, - Section: section, - Profile: resolved.Name, - Message: "credential profile " + resolved.Name + " for " + section + " is missing required credentials", - } - } - return nil -} - -func profileNameFromResolveError(err error) string { - var cliErr *output.Error - if !errors.As(err, &cliErr) || cliErr.Context == nil { - return "" - } - for _, key := range []string{"requested", "profile"} { - if value, ok := cliErr.Context[key].(string); ok { - return strings.TrimSpace(value) - } - } - return "" -} - func manifestWorkspaceID(m *Manifest) string { if m == nil || m.Workspace == nil { return "" @@ -325,58 +179,11 @@ func manifestWorkspaceID(m *Manifest) string { return strings.TrimSpace(m.Workspace.ID) } -func profileComplete(backend string, p profile.Profile) bool { - switch { - case p.Infisical != nil: - c := p.Infisical.Credentials - return c != nil && strings.TrimSpace(c.ClientID) != "" && strings.TrimSpace(c.ClientSecret) != "" - case p.S3 != nil: - c := p.S3.Credentials - return c != nil && strings.TrimSpace(c.AccessKeyID) != "" && strings.TrimSpace(c.AccessKeySecret) != "" - case p.Vercel != nil: - return p.Vercel.Credentials != nil && strings.TrimSpace(p.Vercel.Credentials.APIToken) != "" - case p.Cloudflare != nil: - return p.Cloudflare.Credentials != nil && strings.TrimSpace(p.Cloudflare.Credentials.APIToken) != "" - case p.Container != nil: - c := p.Container.Credentials - return c != nil && strings.TrimSpace(c.Username) != "" && strings.TrimSpace(c.Password) != "" - case p.Dotenv != nil: - return true - case p.Kustomize != nil: - // kubectl's normal lookup chain already supports an omitted - // kubeconfigPath (KUBECONFIG, then ~/.kube/config). Kustomize still - // needs an explicit Profile object so the user can choose a machine - // connection/context, but the path inside that object is optional. - return true - case p.EdgeOne != nil: - // EdgeOne supports `edgeone login`; an inline token is useful but not mandatory. - return true - default: - _ = backend - return false - } -} - -// projectResolvedDomains collapses workspace defaults + per-project -// overrides into a single domain→kind map (same logic the UI would re-do). -// "container" only appears when the project actually opted in via its own -// override OR a workspace-level default exists; that matches what -// container-related commands actually run. func projectResolvedDomains(m *Manifest, p *ManifestProject) map[string]string { out := map[string]string{} if env := EnvBackend(m); env != "" { out[IssueDomainEnv] = env } - if sel := DeployForProject(m, p.Name); sel.Backend != "" { - out[IssueDomainDeploy] = sel.Backend - } else if m.Domains != nil && m.Domains.Deploy != nil && m.Domains.Deploy.Kind != "" { - out[IssueDomainDeploy] = m.Domains.Deploy.Kind - } - if enabled, _ := ContainerForProject(m, p.Name); enabled { - out[IssueDomainContainer] = ContainerKindForProject(m, p.Name) - } else if projectNeedsContainer(m, p) && m.Domains != nil && m.Domains.Container != nil && m.Domains.Container.Kind != "" { - out[IssueDomainContainer] = m.Domains.Container.Kind - } if len(out) == 0 { return nil } @@ -393,23 +200,6 @@ func projectResolvedDomains(m *Manifest, p *ManifestProject) map[string]string { return ordered } -func projectEffectiveDeploy(m *Manifest, p *ManifestProject) string { - if m == nil || p == nil { - return "" - } - if sel := DeployForProject(m, p.Name); sel.Backend != "" { - return sel.Backend - } - if m.Domains != nil && m.Domains.Deploy != nil { - return strings.TrimSpace(m.Domains.Deploy.Kind) - } - return "" -} - -func projectNeedsContainer(m *Manifest, p *ManifestProject) bool { - return projectEffectiveDeploy(m, p) == DeployBackendKustomize -} - // projectKindFromDir maps the manifest's RelativeDir to a coarse "app / // service / package" label. Anything outside the hard-wired roots // (apps/services/packages) falls through to "app" — workspaces only carry diff --git a/packages/cli/internal/core/workspace/overview_test.go b/packages/cli/internal/core/workspace/overview_test.go index 303c7300..ee9d22f8 100644 --- a/packages/cli/internal/core/workspace/overview_test.go +++ b/packages/cli/internal/core/workspace/overview_test.go @@ -1,13 +1,9 @@ package workspace import ( - "encoding/json" "os" "path/filepath" - "slices" "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" ) func withIsolatedOverviewProfiles(t *testing.T) { @@ -45,23 +41,18 @@ func TestBuildOverview_NoManifestAtRoot(t *testing.T) { func TestBuildOverview_FullyConfigured_NoIssues(t *testing.T) { withIsolatedOverviewProfiles(t) tmp := t.TempDir() - deployCfg, _ := json.Marshal(map[string]any{"projectId": "x"}) m := &Manifest{ Version: ManifestVersion, Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, Environments: &Environments{Names: []string{"dev", "prod"}, Default: "dev"}, Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendDotenv}, - Deploy: &BackendRef{Kind: DeployBackendVercel}, - Container: &BackendRef{Kind: ContainerBackendDocker}, + Env: &BackendRef{Kind: EnvBackendDotenv}, }, Projects: []ManifestProject{ { Name: "web", RelativeDir: "apps/web", TemplateID: "react-spa", Toolchain: "node", Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{Kind: DeployBackendVercel, Config: deployCfg}, - Container: &ProjectContainerOverride{Image: "web:latest"}, - Dev: &ProjectDevOverride{Command: "pnpm dev"}, + Dev: &ProjectDevOverride{Command: "pnpm dev"}, }, }, }, @@ -69,21 +60,6 @@ func TestBuildOverview_FullyConfigured_NoIssues(t *testing.T) { if err := WriteManifest(tmp, m); err != nil { t.Fatalf("WriteManifest: %v", err) } - if _, err := profile.Upsert(profile.DomainDeploy, DeployBackendVercel, "prod", profile.Profile{ - Backend: DeployBackendVercel, - Vercel: &profile.VercelProfile{Credentials: &profile.VercelCredentials{APIToken: "token"}}, - }, true); err != nil { - t.Fatalf("upsert vercel profile: %v", err) - } - if _, err := profile.Upsert(profile.DomainContainer, ContainerBackendDocker, "prod", profile.Profile{ - Backend: ContainerBackendDocker, - Container: &profile.ContainerProfile{ - Registry: "registry.example.com", - Credentials: &profile.ContainerCredentials{Username: "user", Password: "pass"}, - }, - }, true); err != nil { - t.Fatalf("upsert container profile: %v", err) - } ov, err := BuildOverview(tmp) if err != nil { t.Fatalf("BuildOverview: %v", err) @@ -103,187 +79,11 @@ func TestBuildOverview_FullyConfigured_NoIssues(t *testing.T) { if ov.Projects[0].Kind != ProjectKindApp { t.Errorf("kind = %q; want %q", ov.Projects[0].Kind, ProjectKindApp) } - if got := ov.Projects[0].CompatibleDeployTargets; !slices.Equal(got, []string{ - "aliyun-oss", "tencent-cos", "aws-s3", "minio", "rustfs", "r2", "vercel", "cloudflare", "edgeone", - }) { - t.Fatalf("compatible deploy targets = %v", got) - } if ov.Workspace.Domains["env"] != EnvBackendDotenv { t.Errorf("workspace env domain = %q", ov.Workspace.Domains["env"]) } - if ov.Projects[0].Domains["deploy"] != DeployBackendVercel { - t.Errorf("project deploy domain = %q", ov.Projects[0].Domains["deploy"]) - } -} - -func TestBuildOverview_KustomizeRequiresProfileButNotKubeconfigPath(t *testing.T) { - withIsolatedOverviewProfiles(t) - root := t.TempDir() - manifest := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendDotenv}, - Deploy: &BackendRef{Kind: DeployBackendKustomize}, - Container: &BackendRef{Kind: ContainerBackendDocker}, - }, - Projects: []ManifestProject{{ - Name: "api", RelativeDir: "services/api", TemplateID: "go-api", Toolchain: "go", - Domains: &ProjectDomains{Container: &ProjectContainerOverride{}}, - }}, - } - if err := WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - if _, err := profile.Upsert(profile.DomainContainer, ContainerBackendDocker, "registry", profile.Profile{ - Backend: ContainerBackendDocker, - Container: &profile.ContainerProfile{ - Registry: "registry.example.com", - Credentials: &profile.ContainerCredentials{ - Username: "user", Password: "pass", - }, - }, - }, true); err != nil { - t.Fatal(err) - } - - missing, err := BuildOverview(root) - if err != nil { - t.Fatal(err) - } - if len(missing.Projects) != 1 || len(missing.Projects[0].Issues) != 1 { - t.Fatalf("missing kustomize profile issues = %#v", missing.Projects) - } - issue := missing.Projects[0].Issues[0] - if issue.Domain != IssueDomainDeploy || issue.Backend != DeployBackendKustomize || - issue.Reason != IssueReasonProfile { - t.Fatalf("kustomize issue = %#v", issue) - } - - if _, err := profile.Upsert(profile.DomainDeploy, DeployBackendKustomize, "current-context", profile.Profile{ - Backend: DeployBackendKustomize, - Kustomize: &profile.KustomizeProfile{}, - }, true); err != nil { - t.Fatal(err) - } - configured, err := BuildOverview(root) - if err != nil { - t.Fatal(err) - } - if len(configured.Projects[0].Issues) != 0 { - t.Fatalf("empty kubeconfigPath should use kubectl defaults: %#v", configured.Projects[0].Issues) - } -} - -// apps project with no deploy override and no workspace default → one -// deploy issue. Container is only required once the effective deploy target -// is kustomize. -func TestBuildOverview_AppMissingDeployOnly(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendDotenv}, - }, - Projects: []ManifestProject{ - {Name: "web", RelativeDir: "apps/web", TemplateID: "react-spa", Toolchain: "node"}, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - if len(ov.Issues) != 0 { - t.Errorf("workspace issues = %+v; want none (env is set)", ov.Issues) - } - if got := len(ov.Projects[0].Issues); got != 1 { - t.Fatalf("project issues = %d; want 1 (deploy)", got) - } - domains := map[string]bool{} - for _, iss := range ov.Projects[0].Issues { - domains[iss.Domain] = true - if iss.Severity != IssueSeverityMissing { - t.Errorf("severity = %q", iss.Severity) - } - } - if !domains[IssueDomainDeploy] { - t.Errorf("missing deploy issue") - } - if domains[IssueDomainContainer] { - t.Errorf("container should not be required before kustomize deploy is selected") - } -} - -func TestBuildOverview_NonDeployableAppDoesNotReportMissingDeploy(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendDotenv}, - }, - Projects: []ManifestProject{ - {Name: "mobile", RelativeDir: "apps/mobile", TemplateID: "expo-mobile", Toolchain: "node"}, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - project := ov.Projects[0] - if len(project.CompatibleDeployTargets) != 0 { - t.Fatalf("expo-mobile deploy targets = %v; want none", project.CompatibleDeployTargets) - } - for _, issue := range project.Issues { - if issue.Domain == IssueDomainDeploy { - t.Fatalf("non-deployable app must not report a missing deploy target: %+v", project.Issues) - } - } -} - -func TestBuildOverview_CloudflareDeployDoesNotRequireContainer(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendDotenv}, - Container: &BackendRef{Kind: "dockerhub"}, - }, - Projects: []ManifestProject{ - {Name: "site", RelativeDir: "apps/site", TemplateID: "astro-site", Toolchain: "node", - Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{Kind: DeployBackendCloudflare}, - Dev: &ProjectDevOverride{Command: "pnpm dev"}, - }, - }, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - p := ov.Projects[0] - if got := p.Domains[IssueDomainContainer]; got != "" { - t.Fatalf("cloudflare project container domain = %q, want empty", got) - } - for _, iss := range p.Issues { - if iss.Domain == IssueDomainContainer { - t.Fatalf("cloudflare project should not require container: %+v", p.Issues) - } + if len(ov.Projects[0].Domains) != 1 || ov.Projects[0].Domains["env"] != EnvBackendDotenv { + t.Errorf("project domains = %#v; want only dotenv", ov.Projects[0].Domains) } } @@ -298,9 +98,7 @@ func TestBuildOverview_EnvWorkspaceLevelOnly(t *testing.T) { Projects: []ManifestProject{ {Name: "web", RelativeDir: "apps/web", TemplateID: "react-spa", Toolchain: "node", Domains: &ProjectDomains{ - Deploy: &ProjectDeployBackend{Kind: DeployBackendVercel}, - Container: &ProjectContainerOverride{Image: "x"}, - Dev: &ProjectDevOverride{Command: "pnpm dev"}, + Dev: &ProjectDevOverride{Command: "pnpm dev"}, }, }, }, @@ -352,193 +150,6 @@ func TestBuildOverview_PackagesSkipDomainChecks(t *testing.T) { } } -// Workspace-level container/deploy defaults suppress per-project misses -// (project inherits the workspace default). -func TestBuildOverview_WorkspaceDefaultsSuppressProjectMisses(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendDotenv}, - Deploy: &BackendRef{Kind: DeployBackendKustomize}, - Container: &BackendRef{Kind: ContainerBackendDocker}, - }, - Projects: []ManifestProject{ - {Name: "api", RelativeDir: "services/api", TemplateID: "go-api", Toolchain: "go", - Domains: &ProjectDomains{Dev: &ProjectDevOverride{Command: "go run ."}}, - }, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - if _, err := profile.Upsert(profile.DomainContainer, ContainerBackendDocker, "prod", profile.Profile{ - Backend: ContainerBackendDocker, - Container: &profile.ContainerProfile{ - Registry: "registry.example.com", - Credentials: &profile.ContainerCredentials{Username: "user", Password: "pass"}, - }, - }, true); err != nil { - t.Fatalf("upsert container profile: %v", err) - } - if _, err := profile.Upsert(profile.DomainDeploy, DeployBackendKustomize, "cluster", profile.Profile{ - Backend: DeployBackendKustomize, - Kustomize: &profile.KustomizeProfile{}, - }, true); err != nil { - t.Fatalf("upsert kustomize profile: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - if len(ov.Issues) != 0 { - t.Errorf("workspace issues = %+v; want none", ov.Issues) - } - if len(ov.Projects[0].Issues) != 0 { - t.Errorf("project issues = %+v; want none (workspace defaults cover everything)", - ov.Projects[0].Issues) - } - if ov.Projects[0].Kind != ProjectKindService { - t.Errorf("kind = %q; want %q", ov.Projects[0].Kind, ProjectKindService) - } -} - -func TestBuildOverview_SelectedBackendMissingCredentials(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendInfisical}, - }, - Projects: []ManifestProject{ - {Name: "web", RelativeDir: "apps/web", TemplateID: "react-spa", Toolchain: "node"}, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - if _, err := profile.Upsert(profile.DomainEnv, EnvBackendInfisical, "empty", profile.Profile{ - Backend: EnvBackendInfisical, - Infisical: &profile.InfisicalProfile{SiteURL: "https://app.infisical.com"}, - }, true); err != nil { - t.Fatalf("upsert infisical profile: %v", err) - } - - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - if len(ov.Issues) != 1 { - t.Fatalf("workspace issues = %+v; want one credential issue", ov.Issues) - } - iss := ov.Issues[0] - if iss.Domain != IssueDomainEnv || iss.Reason != IssueReasonProfile || iss.Backend != EnvBackendInfisical { - t.Fatalf("issue = %+v; want env profile issue for infisical", iss) - } - if iss.Section != "env/infisical" || iss.Profile != "empty" { - t.Fatalf("issue section/profile = %q/%q", iss.Section, iss.Profile) - } -} - -func TestBuildOverviewResolvesProfileForSelectedEnvironmentWithoutWritingManifest(t *testing.T) { - withIsolatedOverviewProfiles(t) - root := t.TempDir() - manifest := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Environments: &Environments{Names: []string{"dev", "staging", "prod"}, Default: "dev"}, - Domains: &WorkspaceDomains{ - Env: &BackendRef{Kind: EnvBackendInfisical}, - }, - Projects: []ManifestProject{{ - Name: "web", RelativeDir: "apps/web", TemplateID: "react-spa", Toolchain: "node", - }}, - } - if err := WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - if _, err := profile.Upsert(profile.DomainEnv, EnvBackendInfisical, "broken-default", profile.Profile{ - Backend: EnvBackendInfisical, - Infisical: &profile.InfisicalProfile{ - SiteURL: "https://app.infisical.com", - }, - }, true); err != nil { - t.Fatal(err) - } - if _, err := profile.Upsert(profile.DomainEnv, EnvBackendInfisical, "production", profile.Profile{ - Backend: EnvBackendInfisical, - Infisical: &profile.InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &profile.InfisicalCredentials{ - ClientID: "client", ClientSecret: "secret", - }, - }, - }, false); err != nil { - t.Fatal(err) - } - if _, err := profile.Upsert(profile.DomainEnv, EnvBackendInfisical, "legacy-preview", profile.Profile{ - Backend: EnvBackendInfisical, - Infisical: &profile.InfisicalProfile{ - SiteURL: "https://app.infisical.com", - Credentials: &profile.InfisicalCredentials{ - ClientID: "preview-client", ClientSecret: "preview-secret", - }, - }, - }, false); err != nil { - t.Fatal(err) - } - if err := profile.BindEnvironmentProfile( - "demo", "demo", root, "", "prod", - profile.DomainEnv, EnvBackendInfisical, "production", - ); err != nil { - t.Fatal(err) - } - if err := profile.BindEnvironmentProfile( - "demo", "demo", root, "", "staging", - profile.DomainEnv, EnvBackendInfisical, "legacy-preview", - ); err != nil { - t.Fatal(err) - } - manifestPath := filepath.Join(root, ManifestFilename) - before, err := os.ReadFile(manifestPath) - if err != nil { - t.Fatal(err) - } - - production, err := BuildOverview(root, "prod") - if err != nil { - t.Fatal(err) - } - if production.Environment != "prod" || len(production.Issues) != 0 { - t.Fatalf("prod overview = %#v", production) - } - preview, err := BuildOverview(root, "preview") - if err != nil { - t.Fatal(err) - } - if preview.Environment != "preview" || len(preview.Issues) != 0 { - t.Fatalf("preview overview = %#v", preview) - } - development, err := BuildOverview(root, "dev") - if err != nil { - t.Fatal(err) - } - if len(development.Issues) != 1 || development.Issues[0].Profile != "broken-default" { - t.Fatalf("dev overview = %#v", development) - } - after, err := os.ReadFile(manifestPath) - if err != nil { - t.Fatal(err) - } - if string(after) != string(before) { - t.Fatal("BuildOverview changed one.manifest.json") - } -} - func TestBuildOverview_RejectsBadManifest(t *testing.T) { tmp := t.TempDir() if err := os.WriteFile(filepath.Join(tmp, ManifestFilename), []byte("not json"), 0o644); err != nil { diff --git a/packages/cli/internal/core/workspace/package_manager.go b/packages/cli/internal/core/workspace/package_manager.go new file mode 100644 index 00000000..25d17b12 --- /dev/null +++ b/packages/cli/internal/core/workspace/package_manager.go @@ -0,0 +1,43 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +// ResolvePackageManager shares one choice between dependency preparation and +// operation execution: workspace declaration, project declaration, lockfile, +// then the default used by newly created workspaces. +func ResolvePackageManager(root, fallback string) (string, error) { + manager := strings.TrimSpace(fallback) + pkg, err := ReadPackageJSON(root) + if err != nil { + return "", err + } + if pkg != nil && strings.TrimSpace(pkg.PackageManager) != "" { + manager = strings.TrimSpace(pkg.PackageManager) + } + manager, _, _ = strings.Cut(manager, "@") + if manager == "" { + for _, item := range []struct{ file, manager string }{{"pnpm-lock.yaml", "pnpm"}, {"bun.lock", "bun"}, {"bun.lockb", "bun"}, {"yarn.lock", "yarn"}, {"package-lock.json", "npm"}} { + if _, err := os.Stat(filepath.Join(root, item.file)); err == nil { + manager = item.manager + break + } else if !os.IsNotExist(err) { + return "", err + } + } + } + if manager == "" { + manager = "pnpm" + } + switch manager { + case "pnpm", "npm", "yarn", "bun": + return manager, nil + default: + return "", i18n.Errorf("workspace.package_manager_unsupported", manager) + } +} diff --git a/packages/cli/internal/core/workspace/package_manager_test.go b/packages/cli/internal/core/workspace/package_manager_test.go new file mode 100644 index 00000000..3921594e --- /dev/null +++ b/packages/cli/internal/core/workspace/package_manager_test.go @@ -0,0 +1,38 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolvePackageManagerPrecedence(t *testing.T) { + root := t.TempDir() + put := func(name, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + check := func(fallback, want string) { + t.Helper() + got, err := ResolvePackageManager(root, fallback) + if err != nil || got != want { + t.Fatalf("got %q %v, want %q", got, err, want) + } + } + check("", "pnpm") + put("package-lock.json", "{}") + check("", "npm") + check("yarn@4.0.0", "yarn") + put("package.json", `{"packageManager":"bun@1.0.0"}`) + check("yarn", "bun") + put("package.json", `{"packageManager":"unknown@1.0.0"}`) + if _, err := ResolvePackageManager(root, ""); err == nil { + t.Fatal("invalid manager accepted") + } + put("package.json", "malformed") + if _, err := ResolvePackageManager(root, ""); err == nil { + t.Fatal("invalid package ignored") + } +} diff --git a/packages/cli/internal/core/workspace/retired_domains_test.go b/packages/cli/internal/core/workspace/retired_domains_test.go new file mode 100644 index 00000000..d90cdd1f --- /dev/null +++ b/packages/cli/internal/core/workspace/retired_domains_test.go @@ -0,0 +1,49 @@ +package workspace + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadManifestRejectsRetiredDomainsWithoutRewritingFiles(t *testing.T) { + for _, domain := range []string{"deploy", "container"} { + for _, scope := range []string{"workspace", "project"} { + t.Run(scope+"/"+domain, func(t *testing.T) { + root := t.TempDir() + domains := fmt.Sprintf(`{"%s":{"kind":"old"}}`, domain) + manifest := fmt.Sprintf(`{"version":1,"domains":%s,"projects":[]}`, domains) + if scope == "project" { + manifest = fmt.Sprintf(`{"version":1,"projects":[{"name":"web","relativeDir":"apps/web","toolchain":"node","domains":%s}]}`, domains) + } + path := filepath.Join(root, ManifestFilename) + before := []byte(manifest) + if err := os.WriteFile(path, before, 0o644); err != nil { + t.Fatal(err) + } + artifact := filepath.Join(root, "Dockerfile") + content := []byte("FROM scratch\n") + if err := os.WriteFile(artifact, content, 0o644); err != nil { + t.Fatal(err) + } + _, err := ReadManifest(root) + if err == nil || !strings.Contains(err.Error(), "have been removed") { + t.Fatalf("error = %v", err) + } + coded, ok := err.(interface{ ErrorCode() string }) + if !ok || coded.ErrorCode() != "MANIFEST_INVALID" { + t.Fatalf("error code = %v", err) + } + for name, want := range map[string][]byte{path: before, artifact: content} { + got, err := os.ReadFile(name) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("file changed: %s (%v)", name, err) + } + } + }) + } + } +} diff --git a/packages/cli/internal/core/workspace/summary.go b/packages/cli/internal/core/workspace/summary.go index 7a237be7..99ca30aa 100644 --- a/packages/cli/internal/core/workspace/summary.go +++ b/packages/cli/internal/core/workspace/summary.go @@ -33,7 +33,6 @@ type SummaryProject struct { CanStartDevelopment bool `json:"can_start_development"` DependenciesInstalled bool `json:"dependencies_installed"` DependenciesStatus string `json:"dependencies_status,omitempty"` - DeploymentConfigured bool `json:"deployment_configured"` } type SummaryIssue struct { @@ -86,14 +85,12 @@ func BuildSummary(root string) (Summary, error) { if p.Toolchain == "go" { dependencyStatus = "unverified" } - deployConfigured := strings.TrimSpace(DeployForProject(m, p.Name).Backend) != "" s.Projects = append(s.Projects, SummaryProject{ Name: p.Name, RelativeDir: p.RelativeDir, CanStartDevelopment: canDevelop, DependenciesInstalled: dependenciesInstalled, DependenciesStatus: dependencyStatus, - DeploymentConfigured: deployConfigured, }) if canDevelop && !dependenciesInstalled && dependencyStatus != "unverified" { s.Issues = append(s.Issues, SummaryIssue{Code: "dependencies_not_installed", Project: p.Name}) @@ -101,9 +98,6 @@ func BuildSummary(root string) (Summary, error) { if !canDevelop { s.Issues = append(s.Issues, SummaryIssue{Code: "development_not_available", Project: p.Name}) } - if projectKindFromDir(p.RelativeDir) != ProjectKindPackage && !deployConfigured { - s.Issues = append(s.Issues, SummaryIssue{Code: "deployment_not_configured", Project: p.Name}) - } } s.NextCommand = bestNextCommand(s.Projects) @@ -124,6 +118,26 @@ func ProjectDependenciesInstalled(root, projectDir, toolchain string) bool { if strings.TrimSpace(toolchain) != "node" { return true } + rel, err := filepath.Rel(root, projectDir) + if err != nil { + return false + } + if rel == "." { + return nodePackageDependenciesInstalled(root, projectDir) + } + dirs, err := NodeProjectPackageDirs(root, rel, nil) + if err != nil { + return false + } + for _, dir := range dirs { + if !nodePackageDependenciesInstalled(root, filepath.Join(root, dir)) { + return false + } + } + return true +} + +func nodePackageDependenciesInstalled(root, projectDir string) bool { pkg, err := ReadPackageJSON(projectDir) if err != nil || pkg == nil { return false @@ -176,11 +190,7 @@ func (s *Summary) RenderTTY(w io.Writer) { dev = i18n.T("workspace.dependencies_unverified") } } - deploy := i18n.T("workspace.deploy_missing") - if p.DeploymentConfigured { - deploy = i18n.T("workspace.deploy_ready") - } - fmt.Fprintf(w, " %s %s %s\n", p.Name, dev, deploy) + fmt.Fprintf(w, " %s %s\n", p.Name, dev) } fmt.Fprintf(w, i18n.T("workspace.environment")+"\n", environmentSourceLabel(s.EnvironmentSource), s.DefaultEnvironment) if len(s.Issues) > 0 { diff --git a/packages/cli/internal/core/workspace/walkup.go b/packages/cli/internal/core/workspace/walkup.go index 7ff46d7a..8bc7bef9 100644 --- a/packages/cli/internal/core/workspace/walkup.go +++ b/packages/cli/internal/core/workspace/walkup.go @@ -15,6 +15,7 @@ import ( "strings" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // WalkUpToManifest resolves a possibly-empty --dir flag to an absolute @@ -55,7 +56,7 @@ func WalkUpToManifest(dirFlag string) (string, error) { parent := filepath.Dir(cur) if parent == cur { return "", cliErrors.New(cliErrors.NOT_ONE_PROJECT, - "找不到 one.manifest.json:从 "+start+" 向上每一级都没找到。") + i18n.Tf("workspace.not_found", start)) } cur = parent } diff --git a/packages/cli/internal/modules/build/plan.go b/packages/cli/internal/modules/build/plan.go new file mode 100644 index 00000000..74a772eb --- /dev/null +++ b/packages/cli/internal/modules/build/plan.go @@ -0,0 +1,195 @@ +// Package build plans and executes finite workspace build tasks. +package build + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" +) + +type Task struct { + Project string `json:"project"` + Directory string `json:"directory"` + Argv []string `json:"argv,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + ExitCode int `json:"exit_code"` + DurationMS int64 `json:"duration_ms"` +} + +type Plan struct { + Schema string `json:"schema"` + Runtime string `json:"runtime"` + Environment string `json:"environment,omitempty"` + DryRun bool `json:"dry_run"` + Tasks []Task `json:"tasks"` +} + +type nodePackage struct { + Directory string `json:"-"` + Name string `json:"name"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + OptionalDependencies map[string]string `json:"optionalDependencies"` +} + +// NewPlan reads project configuration only. It never prepares a runtime, +// installs dependencies, loads secrets, or runs a child command. +func NewPlan(w execution.Workspace, selector, environment string) (*Plan, error) { + var selectors []string + if selector != "" { + selectors = []string{selector} + } + return NewPlanForProjects(w, selectors, environment) +} + +func NewPlanForProjects(w execution.Workspace, selectors []string, environment string) (*Plan, error) { + kind, err := execution.RuntimeKind(w.Root()) + if err != nil { + return nil, err + } + environment, _, err = secrets.ResolveEnvName(w.Root(), environment, false) + if err != nil { + return nil, err + } + projects := w.Projects() + if len(selectors) > 0 { + names, err := w.SelectProjects(selectors, "") + if err != nil { + return nil, err + } + projects = projects[:0:0] + for _, name := range names { + p, _ := w.Project(name) + projects = append(projects, *p) + } + } + plan := &Plan{Schema: "one-cli/build-plan/v1", Runtime: kind, Environment: environment, DryRun: true, Tasks: []Task{}} + packages := map[string][]nodePackage{} + names := map[string]string{} + directories := map[string]string{} + tasks := map[string]Task{} + order := []string{} + ready := 0 + for _, p := range projects { + task := Task{Project: p.Name, Directory: p.TargetDir, Status: "pending"} + task.Argv, err = execution.OperationArgs(w, p.Name, "build") + if err != nil { + var missing *output.Error + if len(selectors) > 0 || !errors.As(err, &missing) || missing.Code != string(cliErrors.RUNTIME_TASK_NOT_FOUND) { + return nil, fmt.Errorf("%s: %w", p.Name, err) + } + task.Status, task.Reason = "skipped", "no-build-task" + } else { + ready++ + } + if p.Toolchain == "node" && len(projects) > 1 { + rel, err := filepath.Rel(w.Root(), p.TargetDir) + if err != nil { + return nil, err + } + dirs, err := workspace.NodeProjectPackageDirs(w.Root(), rel, nil) + if err != nil { + return nil, err + } + for _, dir := range dirs { + absolute := filepath.Join(w.Root(), dir) + raw, err := os.ReadFile(filepath.Join(absolute, "package.json")) + if err != nil { + return nil, err + } + var pkg nodePackage + if err := json.Unmarshal(raw, &pkg); err != nil { + return nil, err + } + pkg.Directory = absolute + if pkg.Name != "" { + if previous, ok := names[pkg.Name]; ok { + return nil, i18n.Errorf("build.duplicate_package", pkg.Name, previous, p.Name) + } + names[pkg.Name] = p.Name + } + packages[p.Name] = append(packages[p.Name], pkg) + directories[filepath.Clean(absolute)] = p.Name + } + } + tasks[p.Name] = task + order = append(order, p.Name) + } + if ready == 0 { + return nil, cliErrors.New(cliErrors.RUNTIME_TASK_NOT_FOUND, i18n.T("build.no_tasks")) + } + // Package names, not manifest aliases, identify local Node dependencies. + // Keep manifest order among otherwise independent projects. + for name, members := range packages { + local := map[string]bool{} + for _, pkg := range members { + for _, deps := range []map[string]string{pkg.Dependencies, pkg.DevDependencies, pkg.OptionalDependencies} { + for dep, spec := range deps { + target := names[dep] + for _, prefix := range []string{"file:", "link:"} { + if strings.HasPrefix(spec, prefix) { + target = directories[filepath.Clean(filepath.Join(pkg.Directory, strings.TrimPrefix(spec, prefix)))] + } + } + if target != "" && (target != name || len(members) == 1) { + local[target] = true + } + } + } + } + task := tasks[name] + for _, dep := range order { + if local[dep] { + task.Dependencies = append(task.Dependencies, dep) + } + } + tasks[name] = task + } + state := map[string]int{} + stack := []string{} + var visit func(string) error + visit = func(name string) error { + if state[name] == 2 { + return nil + } + if state[name] == 1 { + start := 0 + for i, item := range stack { + if item == name { + start = i + break + } + } + return i18n.Errorf("build.dependency_cycle", strings.Join(append(append([]string{}, stack[start:]...), name), " -> ")) + } + state[name] = 1 + stack = append(stack, name) + for _, dep := range tasks[name].Dependencies { + if err := visit(dep); err != nil { + return err + } + } + stack = stack[:len(stack)-1] + state[name] = 2 + plan.Tasks = append(plan.Tasks, tasks[name]) + return nil + } + for _, name := range order { + if err := visit(name); err != nil { + return nil, err + } + } + return plan, nil +} diff --git a/packages/cli/internal/modules/build/plan_test.go b/packages/cli/internal/modules/build/plan_test.go new file mode 100644 index 00000000..edc7f6e1 --- /dev/null +++ b/packages/cli/internal/modules/build/plan_test.go @@ -0,0 +1,214 @@ +package build + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" +) + +func write(t *testing.T, root, path, contents string) { + t.Helper() + target := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(contents), 0644); err != nil { + t.Fatal(err) + } +} + +func fixture(t *testing.T) execution.Workspace { + t.Helper() + root := t.TempDir() + t.Setenv("ONE_RUNTIME", "builtin") + manifest := workspace.Manifest{Version: 1, Workspace: &workspace.ManifestWorkspace{ID: "build-test", Name: "build-test"}, Projects: []workspace.ManifestProject{ + {Name: "web", RelativeDir: "apps/web", Toolchain: "node"}, + {Name: "library", RelativeDir: "packages/lib", Toolchain: "node"}, + {Name: "api", RelativeDir: "services/api", Toolchain: "go"}, + {Name: "mobile", RelativeDir: "apps/mobile", Toolchain: "node"}, + }} + raw, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + write(t, root, "one.manifest.json", string(raw)) + write(t, root, "package.json", `{"packageManager":"npm@11.0.0"}`) + write(t, root, "apps/web/package.json", `{"name":"@test/web","scripts":{"build":"build-web"},"dependencies":{"@test/lib":"workspace:*"}}`) + write(t, root, "packages/lib/package.json", `{"name":"@test/lib","scripts":{"build":"build-lib"}}`) + write(t, root, "services/api/Taskfile.yml", "version: '3'\ntasks:\n build:\n cmds: ['go build -o bin/server ./cmd/server']\n") + write(t, root, "apps/mobile/package.json", `{"name":"@test/mobile","scripts":{"start":"start-mobile"}}`) + scope := execution.NewScope(context.Background(), root) + t.Cleanup(func() { _ = scope.Close(context.Background()) }) + w, err := execution.ResolveWorkspaceScope(scope) + if err != nil { + t.Fatal(err) + } + return w +} + +func TestPlanOrdersLibrariesAndSkipsMissingTasks(t *testing.T) { + w := fixture(t) + p, err := NewPlan(w, "", "") + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, task := range p.Tasks { + names = append(names, task.Project) + } + if !reflect.DeepEqual(names, []string{"library", "web", "api", "mobile"}) { + t.Fatal(names) + } + if !reflect.DeepEqual(p.Tasks[0].Argv, []string{"npm", "run", "build"}) || !reflect.DeepEqual(p.Tasks[2].Argv, []string{"task", "build"}) { + t.Fatal(p.Tasks) + } + if p.Tasks[3].Status != "skipped" || p.Tasks[3].Reason != "no-build-task" { + t.Fatal(p.Tasks[3]) + } + if !p.DryRun || p.Schema != "one-cli/build-plan/v1" { + t.Fatal(p) + } + if _, err := os.Stat(filepath.Join(w.Root(), "node_modules")); !os.IsNotExist(err) { + t.Fatal("planning prepared dependencies") + } +} + +func TestPlanSelectionAndErrors(t *testing.T) { + w := fixture(t) + for _, selector := range []string{"web", "apps/web", "./apps/web/"} { + p, err := NewPlan(w, selector, "") + if err != nil || len(p.Tasks) != 1 || p.Tasks[0].Project != "web" { + t.Fatalf("%s: %+v %v", selector, p, err) + } + } + for selector, code := range map[string]string{"unknown": "SUBPROJECT_NOT_FOUND", "mobile": "RUNTIME_TASK_NOT_FOUND"} { + _, err := NewPlan(w, selector, "") + var coded *output.Error + if !errors.As(err, &coded) || coded.Code != code { + t.Fatalf("%s: %v", selector, err) + } + } + // Unrelated malformed projects must not block a selected build. + write(t, w.Root(), "packages/lib/package.json", "broken") + if _, err := NewPlan(w, "web", ""); err != nil { + t.Fatal(err) + } + if _, err := NewPlan(w, "", ""); err == nil { + t.Fatal("malformed package ignored") + } +} + +func TestPlanRejectsCycleAndDuplicatePackageNames(t *testing.T) { + w := fixture(t) + write(t, w.Root(), "packages/lib/package.json", `{"name":"@test/lib","scripts":{"build":"build-lib"},"devDependencies":{"@test/web":"workspace:*"}}`) + if _, err := NewPlan(w, "", ""); err == nil || !strings.Contains(err.Error(), "web -> library -> web") { + t.Fatal(err) + } + write(t, w.Root(), "packages/lib/package.json", `{"name":"@test/web","scripts":{"build":"build-lib"}}`) + if _, err := NewPlan(w, "", ""); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatal(err) + } +} + +func TestPlanLocalDirectoryDependencies(t *testing.T) { + for _, prefix := range []string{"file:", "link:"} { + t.Run(prefix, func(t *testing.T) { + w := fixture(t) + write(t, w.Root(), "apps/web/package.json", `{"name":"@test/web","scripts":{"build":"build-web"},"optionalDependencies":{"alias":"`+prefix+`../../packages/lib"}}`) + p, err := NewPlan(w, "", "") + if err != nil || p.Tasks[0].Project != "library" { + t.Fatalf("%+v %v", p, err) + } + }) + } +} + +func TestGoBuildRequiresTaskfileAndBuildTask(t *testing.T) { + w := fixture(t) + if err := os.Remove(filepath.Join(w.Root(), "services/api/Taskfile.yml")); err != nil { + t.Fatal(err) + } + for _, selector := range []string{"api", ""} { + if _, err := NewPlan(w, selector, ""); err == nil || !strings.Contains(err.Error(), "Taskfile.yml") { + t.Fatal(err) + } + } + write(t, w.Root(), "services/api/Taskfile.yml", "version: '3'\ntasks:\n test:\n cmds: ['go test ./...']\n") + if _, err := NewPlan(w, "api", ""); err == nil { + t.Fatal("missing build accepted") + } + p, err := NewPlan(w, "", "") + if err != nil { + t.Fatal(err) + } + for _, task := range p.Tasks { + if task.Project == "api" && task.Status != "skipped" { + t.Fatal(task) + } + } +} + +func TestEmptyBuildAndInvalidEnvironment(t *testing.T) { + w := fixture(t) + for _, path := range []string{"apps/web/package.json", "packages/lib/package.json"} { + write(t, w.Root(), path, `{"scripts":{}}`) + } + write(t, w.Root(), "services/api/Taskfile.yml", "version: '3'\ntasks: {}\n") + if _, err := NewPlan(w, "", ""); err == nil { + t.Fatal("empty build accepted") + } + w.Manifest().Environments = &workspace.Environments{Names: []string{"dev", "prod"}, Default: "dev"} + raw, _ := json.Marshal(w.Manifest()) + write(t, w.Root(), "one.manifest.json", string(raw)) + if _, err := NewPlan(w, "web", "typo"); err == nil || !strings.Contains(err.Error(), "typo") { + t.Fatal(err) + } +} + +func TestMultipleProjectSelectionOrdersDependenciesAndDeduplicates(t *testing.T) { + w := fixture(t) + p, err := NewPlanForProjects(w, []string{"apps/web", "library", "web"}, "") + if err != nil || len(p.Tasks) != 2 || p.Tasks[0].Project != "library" || p.Tasks[1].Project != "web" { + t.Fatalf("%+v %v", p, err) + } + if _, err := NewPlanForProjects(w, []string{"web", "unknown"}, ""); err == nil { + t.Fatal("unknown project accepted") + } +} + +func TestCompositeBuildDependenciesBelongToParentProject(t *testing.T) { + for _, spec := range []string{"workspace:*", "file:../../../../packages/lib", "link:../../../../packages/lib"} { + t.Run(spec, func(t *testing.T) { + w := fixture(t) + write(t, w.Root(), "apps/web/package.json", `{"name":"desktop","workspaces":["apps/ui","packages/preload"],"scripts":{"build":"build-desktop"}}`) + write(t, w.Root(), "apps/web/apps/ui/package.json", `{"name":"@desktop/ui","dependencies":{"@test/lib":"`+spec+`","@desktop/preload":"workspace:*"}}`) + write(t, w.Root(), "apps/web/packages/preload/package.json", `{"name":"@desktop/preload"}`) + plan, err := NewPlanForProjects(w, []string{"web", "library"}, "") + if err != nil { + t.Fatal(err) + } + if len(plan.Tasks) != 2 || plan.Tasks[0].Project != "library" || !reflect.DeepEqual(plan.Tasks[1].Dependencies, []string{"library"}) { + t.Fatalf("wrong composite order: %+v", plan.Tasks) + } + // A consumer of an internal package depends on its logical owner. + write(t, w.Root(), "apps/web/apps/ui/package.json", `{"name":"@desktop/ui","dependencies":{"@desktop/preload":"workspace:*"}}`) + write(t, w.Root(), "packages/lib/package.json", `{"name":"@test/lib","scripts":{"build":"build-lib"},"dependencies":{"@desktop/preload":"workspace:*"}}`) + plan, err = NewPlanForProjects(w, []string{"library", "web"}, "") + if err != nil { + t.Fatal(err) + } + if plan.Tasks[0].Project != "web" || !reflect.DeepEqual(plan.Tasks[1].Dependencies, []string{"web"}) { + t.Fatalf("wrong internal package owner: %+v", plan.Tasks) + } + }) + } +} diff --git a/packages/cli/internal/modules/build/service.go b/packages/cli/internal/modules/build/service.go new file mode 100644 index 00000000..eeaa0844 --- /dev/null +++ b/packages/cli/internal/modules/build/service.go @@ -0,0 +1,145 @@ +package build + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/dependencies" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/taskrun" +) + +type Runner func(context.Context, string, Task, string, io.Writer) error + +type Service struct { + Prepare func(context.Context, dependencies.Input) error + UI taskrun.Mode + Concurrency int + Run Runner +} + +type Result struct { + *Plan + Error string `json:"error,omitempty"` + ExitCode int `json:"exit_code"` +} + +func (p *Plan) RenderTTY(w io.Writer) { + for _, task := range p.Tasks { + detail := strings.Join(task.Argv, " ") + if task.Reason != "" { + detail = task.Reason + if task.Reason == "no-build-task" { + detail = i18n.T("build.reason.no_task") + } + } + if task.Status == "failed" { + detail = i18n.Tf("build.exit_detail", detail, task.ExitCode) + } + fmt.Fprintf(w, "[%s] %s: %s\n", task.Project, i18n.T("build.status."+task.Status), detail) + } +} + +func (r *Result) RenderTTY(w io.Writer) { + r.Plan.RenderTTY(w) + counts := map[string]int{} + for _, task := range r.Tasks { + counts[task.Status]++ + } + fmt.Fprintf(w, i18n.T("build.summary")+"\n", counts["succeeded"], counts["failed"], counts["skipped"], counts["not_run"]+counts["blocked"]+counts["stopped"]) + if r.Error != "" { + fmt.Fprintln(w, r.Error) + } +} + +// Execute prepares all selected projects before running any build, then runs +// each finite task to completion. A failure leaves remaining tasks not_run. +func (s Service) Execute(ctx context.Context, w execution.Workspace, plan *Plan, log io.Writer) (*Result, error) { + ctx, stop := taskrun.SignalContext(ctx) + defer stop() + copyPlan := *plan + copyPlan.Tasks = append([]Task{}, plan.Tasks...) + copyPlan.Schema, copyPlan.DryRun = "one-cli/build-result/v1", false + result := &Result{Plan: ©Plan} + selected := []string{} + for i := range result.Tasks { + if result.Tasks[i].Status == "pending" { + selected = append(selected, result.Tasks[i].Project) + result.Tasks[i].Status = "not_run" + } + } + if log == nil { + log = io.Discard + } + fail := func(err error) (*Result, error) { + if ctx.Err() != nil { + err = context.Cause(ctx) + } + result.ExitCode = 1 + var exit *platformprocess.ExitStatus + if errors.As(err, &exit) { + result.ExitCode = exit.Code + } else if errors.Is(err, context.Canceled) { + result.ExitCode = 130 + } + result.Error = err.Error() + return result, &platformprocess.ExitStatus{Code: result.ExitCode} + } + if ctx.Err() != nil { + return fail(ctx.Err()) + } + if s.Prepare != nil { + if err := s.Prepare(ctx, dependencies.Input{Root: w.Root(), Manifest: w.Manifest(), Projects: selected, Runtime: plan.Runtime, Log: log}); err != nil { + return fail(err) + } + } + binary, err := os.Executable() + if err != nil { + return fail(err) + } + var tasks []taskrun.Task + byName := map[string]int{} + for i, task := range result.Tasks { + if task.Status == "skipped" { + continue + } + argv := []string{binary, "run", "--project", task.Project, "-o", "json"} + if plan.Environment != "" { + argv = append(argv, "--env", plan.Environment) + } + argv = append(append(argv, "--"), task.Argv...) + tasks = append(tasks, taskrun.Task{Name: task.Project, Directory: w.Root(), Argv: argv, Dependencies: task.Dependencies}) + byName[task.Project] = i + } + opts := taskrun.Options{Mode: s.UI, Title: "build", Concurrency: s.Concurrency, Output: log} + if s.Run != nil { + opts.Run = func(ctx context.Context, t taskrun.Task, out io.Writer) error { + return s.Run(ctx, w.Root(), result.Tasks[byName[t.Name]], plan.Environment, out) + } + } + outcomes, err := taskrun.Run(ctx, tasks, opts) + for _, outcome := range outcomes { + task := &result.Tasks[byName[outcome.Name]] + task.Status = outcome.Status + task.ExitCode = outcome.ExitCode + task.DurationMS = outcome.Duration.Milliseconds() + if outcome.Status == "failed" && result.Error == "" { + result.Error = fmt.Sprintf("%s: %v", outcome.Name, outcome.Err) + } + } + if err != nil { + message := result.Error + res, e := fail(err) + if message != "" { + res.Error = message + } + return res, e + } + return result, nil +} diff --git a/packages/cli/internal/modules/build/service_test.go b/packages/cli/internal/modules/build/service_test.go new file mode 100644 index 00000000..157f2907 --- /dev/null +++ b/packages/cli/internal/modules/build/service_test.go @@ -0,0 +1,107 @@ +package build + +import ( + "context" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/dependencies" + platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" +) + +func TestExecuteWaitsForAllBuildsAndPreparesLibraries(t *testing.T) { + w := fixture(t) + plan, err := NewPlan(w, "", "") + if err != nil { + t.Fatal(err) + } + events := []string{} + service := Service{ + Prepare: func(_ context.Context, in dependencies.Input) error { + if !reflect.DeepEqual(in.Projects, []string{"library", "web", "api"}) { + t.Fatal(in.Projects) + } + events = append(events, "prepare") + return nil + }, + Run: func(_ context.Context, root string, task Task, env string, log io.Writer) error { + if root != w.Root() { + t.Fatal(root) + } + events = append(events, task.Project) + return nil + }, + } + result, err := service.Execute(context.Background(), w, plan, nil) + if err != nil || result.ExitCode != 0 { + t.Fatalf("%+v %v", result, err) + } + if !reflect.DeepEqual(events, []string{"prepare", "library", "web", "api"}) { + t.Fatal(events) + } + for i := 0; i < 3; i++ { + if result.Tasks[i].Status != "succeeded" || plan.Tasks[i].Status != "pending" { + t.Fatal(result, plan) + } + } + if result.DryRun || result.Schema != "one-cli/build-result/v1" || result.Tasks[3].Status != "skipped" { + t.Fatal(result) + } +} + +func TestExecuteStopsOnFailureAndPreservesExitCode(t *testing.T) { + w := fixture(t) + plan, _ := NewPlan(w, "", "") + service := Service{Run: func(_ context.Context, _ string, task Task, _ string, _ io.Writer) error { + if task.Project == "web" { + return &platformprocess.ExitStatus{Code: 42} + } + if task.Project == "api" { + t.Fatal("started after failure") + } + return nil + }} + result, err := service.Execute(context.Background(), w, plan, nil) + var exit *platformprocess.ExitStatus + if !errors.As(err, &exit) || exit.Code != 42 || result.ExitCode != 42 { + t.Fatalf("%+v %v", result, err) + } + if result.Tasks[0].Status != "succeeded" || result.Tasks[1].Status != "failed" || result.Tasks[1].ExitCode != 42 || result.Tasks[2].Status != "not_run" { + t.Fatal(result.Tasks) + } + if !strings.Contains(result.Error, "web") { + t.Fatal(result.Error) + } +} + +func TestPreparationFailureAndCancellationPreventBuilds(t *testing.T) { + for _, cancelled := range []bool{false, true} { + t.Run(fmt.Sprint(cancelled), func(t *testing.T) { + w := fixture(t) + plan, _ := NewPlan(w, "", "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + service := Service{ + Prepare: func(context.Context, dependencies.Input) error { + if cancelled { + cancel() + return ctx.Err() + } + return errors.New("dependency install failed") + }, + Run: func(context.Context, string, Task, string, io.Writer) error { t.Fatal("build started"); return nil }, + } + result, err := service.Execute(ctx, w, plan, nil) + if err == nil || result.Tasks[0].Status != "not_run" { + t.Fatalf("%+v %v", result, err) + } + if cancelled && result.ExitCode != 130 { + t.Fatal(result) + } + }) + } +} diff --git a/packages/cli/internal/modules/container/service.go b/packages/cli/internal/modules/container/service.go deleted file mode 100644 index 45682343..00000000 --- a/packages/cli/internal/modules/container/service.go +++ /dev/null @@ -1,147 +0,0 @@ -// Package container is the compiled container-image feature module. All four -// built-in container backends share one Docker/OCI implementation; the Backend -// Catalog selects profile and registry policy without pretending that four -// independently replaceable providers exist. -package container - -import ( - "context" - "fmt" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/container/docker" - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - containermodel "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" -) - -type Service struct { - catalog *catalog.Catalog -} - -func NewService(backendCatalog *catalog.Catalog) (*Service, error) { - if backendCatalog == nil { - return nil, fmt.Errorf("container: backend catalog is required") - } - service := &Service{catalog: backendCatalog} - for _, spec := range backendCatalog.ForDomain(catalog.DomainContainer) { - if !spec.HasTrait(catalog.TraitOCIRegistry) { - return nil, fmt.Errorf("container: backend %s is not supported by the OCI module", spec.Pair) - } - } - return service, nil -} - -func (s *Service) validate(backend string, capability catalog.Capability) error { - spec, ok := s.catalog.Lookup(catalog.DomainContainer, backend) - if !ok || !spec.Has(capability) || !spec.HasTrait(catalog.TraitOCIRegistry) { - return cliErrors.New( - cliErrors.CONTAINER_KIND_UNKNOWN, - fmt.Sprintf("container backend %q 不支持 capability %q", backend, capability), - ) - } - return nil -} - -func (s *Service) Info( - ctx context.Context, - backend string, - input containermodel.InfoInput, -) (*containermodel.InfoResult, error) { - if err := s.validate(backend, catalog.CapabilityContainerInfo); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - return docker.Info(input) -} - -func (s *Service) Build( - ctx context.Context, - backend string, - input containermodel.BuildInput, -) (*containermodel.BuildResult, error) { - if err := s.validate(backend, catalog.CapabilityContainerBuild); err != nil { - return nil, err - } - result, err := docker.Build(ctx, input) - if err != nil || result == nil || input.DryRun { - return result, err - } - if err := publishBuildResult(input.ProjectRoot, input.Platform, result); err != nil { - return nil, err - } - return result, nil -} - -func publishBuildResult(root, platform string, result *containermodel.BuildResult) error { - for _, entry := range result.Built { - if err := workspace.SetProjectContainerImage(root, entry.Project, entry.Image); err != nil { - return err - } - if err := workspace.SetProjectBuildVersion( - root, entry.Project, containermodel.ImageTagVersion(entry.Image), - ); err != nil { - return err - } - } - if platform != "" { - if err := workspace.SetWorkspaceContainerPlatform(root, platform); err != nil { - return err - } - } - return nil -} - -func (s *Service) Push( - ctx context.Context, - backend string, - input containermodel.PushInput, -) (*containermodel.PushResult, error) { - if err := s.validate(backend, catalog.CapabilityContainerPush); err != nil { - return nil, err - } - result, err := docker.Push(ctx, input) - if err != nil || result == nil || input.DryRun { - return result, err - } - if err := publishPushResult(input.ProjectRoot, result); err != nil { - return nil, err - } - return result, nil -} - -func publishPushResult(root string, result *containermodel.PushResult) error { - for _, entry := range result.Pushed { - if err := workspace.SetProjectContainerImage(root, entry.Project, entry.Image); err != nil { - return err - } - } - return nil -} - -type ResolveRegistryInput struct { - ProjectRoot string - Backend string - Profile string - Project string - Environment string - RequireRegistry bool - SkipDefault bool -} - -func (s *Service) ResolveRegistry(input ResolveRegistryInput) (*containermodel.Registry, error) { - if err := s.validate(input.Backend, catalog.CapabilityContainerBuild); err != nil { - return nil, err - } - return docker.ResolveRegistry(docker.ResolveRegistryInput{ - ProjectRoot: input.ProjectRoot, - Kind: input.Backend, - ProfileFlag: input.Profile, - Subproject: input.Project, - Environment: input.Environment, - RequireRegistry: input.RequireRegistry, - SkipDefault: input.SkipDefault, - }) -} diff --git a/packages/cli/internal/modules/container/service_test.go b/packages/cli/internal/modules/container/service_test.go deleted file mode 100644 index 6ab73ff4..00000000 --- a/packages/cli/internal/modules/container/service_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package container - -import ( - "context" - "os" - "path/filepath" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - containermodel "github.com/torchstellar-team/one-cli/packages/cli/internal/core/container" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestServiceRejectsUnknownBackend(t *testing.T) { - service, err := NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - if _, err := service.Build(context.Background(), "not-in-catalog", containermodel.BuildInput{}); err == nil { - t.Fatal("Build() dispatched an unknown backend") - } -} - -func TestServiceRejectsBackendOutsideCompiledOCIFamily(t *testing.T) { - backendCatalog, err := catalog.New(catalog.BackendSpec{ - ID: catalog.BackendID{Domain: catalog.DomainContainer, Name: "custom"}, - Capabilities: []catalog.Capability{ - catalog.CapabilityContainerInfo, - catalog.CapabilityContainerBuild, - catalog.CapabilityContainerPush, - }, - }) - if err != nil { - t.Fatal(err) - } - if _, err := NewService(backendCatalog); err == nil { - t.Fatal("NewService() accepted a backend outside the compiled OCI family") - } -} - -func TestServiceUsesSharedDockerImplementationForCatalogBackends(t *testing.T) { - root := t.TempDir() - projectDir := filepath.Join(root, "services", "api") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectDir, "Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Projects: []workspace.ManifestProject{{ - Name: "api", - RelativeDir: "services/api", - TemplateID: "go-api", - Toolchain: "go", - BuildVersion: "v0.1.0", - Domains: &workspace.ProjectDomains{ - Container: &workspace.ProjectContainerOverride{}, - }, - }}, - }); err != nil { - t.Fatal(err) - } - - service, err := NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - for _, backend := range []string{"docker", "dockerhub", "ghcr", "acr"} { - result, err := service.Build(context.Background(), backend, containermodel.BuildInput{ - ProjectRoot: root, - Project: "api", - TargetNames: []string{"api"}, - Tag: "v1.2.3", - DryRun: true, - }) - if err != nil { - t.Fatalf("Build(%s): %v", backend, err) - } - if len(result.Built) != 1 || result.Built[0].Image != "api:v1.2.3" { - t.Fatalf("Build(%s) = %#v", backend, result) - } - } -} - -func TestPublishBuildResultOwnsManifestBookkeeping(t *testing.T) { - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Projects: []workspace.ManifestProject{{ - Name: "api", - RelativeDir: "services/api", - TemplateID: "go-api", - Toolchain: "go", - BuildVersion: "v0.1.0", - Domains: &workspace.ProjectDomains{ - Container: &workspace.ProjectContainerOverride{}, - }, - }}, - }); err != nil { - t.Fatal(err) - } - if err := publishBuildResult(root, "linux/amd64", &containermodel.BuildResult{ - Built: []containermodel.BuildEntry{{Project: "api", Image: "ghcr.io/team/api:v2.3.4"}}, - }); err != nil { - t.Fatal(err) - } - manifest, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - project := manifest.Projects[0] - if project.Domains.Container.Image != "ghcr.io/team/api:v2.3.4" || project.BuildVersion != "2.3.4" { - t.Fatalf("published project = %#v", project) - } - if got := workspace.ContainerPlatform(manifest); got != "linux/amd64" { - t.Fatalf("container platform = %q", got) - } -} diff --git a/packages/cli/internal/modules/creation/artifacts.go b/packages/cli/internal/modules/creation/artifacts.go index 7d7002ad..64d80d93 100644 --- a/packages/cli/internal/modules/creation/artifacts.go +++ b/packages/cli/internal/modules/creation/artifacts.go @@ -8,13 +8,7 @@ import ( "path/filepath" "strings" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/container/docker" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/cloudflare" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/edgeone" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/kustomize" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/deploy/vercel" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/dotenv" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/profile" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" ) @@ -25,15 +19,12 @@ import ( type syncProjectOptions struct { ProjectRoot string TargetDir string - ProjectName string - TemplateID string Toolchain toolchain.Toolchain PackageManager toolchain.PackageManager Selected map[string]string } -// syncProject materialises compiled-in project artifacts in dependency order: -// container, dev command, deploy configuration, then environment safety rules. +// syncProject writes the development command and environment safety rules. func syncProject(opts syncProjectOptions) error { tc := opts.Toolchain if tc == "" { @@ -44,32 +35,15 @@ func syncProject(opts syncProjectOptions) error { pm = toolchain.PMpnpm } - adapter := toolchain.Get(tc) scripts, err := loadProjectScripts(opts.TargetDir) if err != nil { return err } - runtime := adapter.ResolveRuntime(toolchain.PlanInput{ - Scripts: scripts, - PackageManager: pm, - TemplateID: opts.TemplateID, - }) - relDir, err := filepath.Rel(opts.ProjectRoot, opts.TargetDir) if err != nil { return err } relDir = filepath.ToSlash(relDir) - workloadName := workspace.ResolveWorkloadName(opts.ProjectName, opts.TargetDir) - - if id := opts.Selected["container"]; id != "" && profile.IsContainerKind(backendName(id)) { - if docker.ShouldSync(opts.TargetDir, adapter) { - if err := docker.Sync(opts.TargetDir, adapter, pm, runtime); err != nil { - return err - } - } - } - if command := workspace.ResolveScaffoldDevCommand(scripts, string(tc), opts.TargetDir); command != "" { if tc == toolchain.Node && pm != toolchain.PMpnpm { command = strings.Replace(command, "pnpm run ", string(pm)+" run ", 1) @@ -79,36 +53,6 @@ func syncProject(opts syncProjectOptions) error { } } - if id := opts.Selected["deploy"]; id != "" { - backend := backendName(id) - switch { - case backend == "kustomize": - if err := kustomize.Sync(opts.ProjectRoot, workloadName, runtime.ContainerPort); err != nil { - return err - } - case workspace.IsS3CompatibleDeploy(backend): - // S3-compatible backends have no sync-time artifact. - case backend == "vercel": - if vercel.ShouldSync(opts.TargetDir) { - if err := vercel.Sync(opts.TargetDir, opts.TemplateID); err != nil { - return err - } - } - case backend == "cloudflare": - if cloudflare.ShouldSync(opts.TargetDir) { - if err := cloudflare.Sync(opts.TargetDir, opts.TemplateID, workloadName); err != nil { - return err - } - } - case backend == "edgeone": - if edgeone.ShouldSync(opts.TargetDir) { - if err := edgeone.Sync(opts.TargetDir, opts.TemplateID, workloadName); err != nil { - return err - } - } - } - } - if id := opts.Selected["env"]; id != "" { switch backendName(id) { case workspace.EnvBackendDotenv, workspace.EnvBackendInfisical: diff --git a/packages/cli/internal/modules/creation/artifacts_test.go b/packages/cli/internal/modules/creation/artifacts_test.go index 50aaf46b..94ecf417 100644 --- a/packages/cli/internal/modules/creation/artifacts_test.go +++ b/packages/cli/internal/modules/creation/artifacts_test.go @@ -35,7 +35,7 @@ func TestSyncProjectOwnsDevAndEnvironmentArtifacts(t *testing.T) { } err := syncProject(syncProjectOptions{ - ProjectRoot: root, TargetDir: targetDir, ProjectName: "web", + ProjectRoot: root, TargetDir: targetDir, Toolchain: toolchain.Node, PackageManager: toolchain.PMpnpm, Selected: map[string]string{"env": "env/infisical"}, }) diff --git a/packages/cli/internal/modules/creation/electron_test.go b/packages/cli/internal/modules/creation/electron_test.go new file mode 100644 index 00000000..9881a639 --- /dev/null +++ b/packages/cli/internal/modules/creation/electron_test.go @@ -0,0 +1,284 @@ +package creation + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "gopkg.in/yaml.v3" +) + +func TestElectronProjectsShareRootWorkspace(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "desktop workspace") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"desktop", "studio"} { + if err := addLanguageProject(t, s, root, "electron-app", name); err != nil { + t.Fatal(err) + } + } + manifest, err := workspace.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + if len(manifest.Projects) != 2 { + t.Fatalf("internal packages became One projects: %+v", manifest.Projects) + } + var config struct { + Packages []string `yaml:"packages"` + AllowBuilds map[string]bool `yaml:"allowBuilds"` + } + raw, err := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(raw, &config); err != nil { + t.Fatal(err) + } + if !config.AllowBuilds["electron"] { + t.Fatal("missing Electron installation policy") + } + for _, name := range []string{"desktop", "studio"} { + base := "apps/" + name + for _, member := range []string{"apps/electron", "apps/ui", "packages/preload"} { + want := base + "/" + member + found := false + for _, pattern := range config.Packages { + found = found || pattern == want + } + if !found { + t.Errorf("missing member %s in %s", want, raw) + } + pkg, err := workspace.ReadPackageJSON(filepath.Join(root, want)) + if err != nil { + t.Fatal(err) + } + if pkg.Name != "@"+name+"/"+filepath.Base(member) { + t.Fatalf("wrong package name: %+v", pkg) + } + if member != "packages/preload" && pkg.Dependencies["@"+name+"/preload"] != "workspace:*" { + t.Fatalf("wrong dependency: %+v", pkg) + } + } + for _, file := range []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"} { + if _, err := os.Stat(filepath.Join(root, base, file)); !os.IsNotExist(err) { + t.Errorf("unexpected project-owned %s", file) + } + } + if err := filepath.WalkDir(filepath.Join(root, base), func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), "@app/") || strings.Contains(string(data), "{{projectName") || strings.HasSuffix(path, ".hbs") { + t.Errorf("unrendered reference in %s", path) + } + return nil + }); err != nil { + t.Fatal(err) + } + } + + // Exercise the generated filter scripts with pnpm and local-only packages. + // Stub package commands avoid a GUI/network while preserving the real + // package names, workspace links and parent orchestration scripts. + pnpm, err := exec.LookPath("pnpm") + if err != nil { + t.Skip("pnpm is not installed") + } + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + t.Setenv("CI", "true") + rewrite := func(rel string, internal bool) { + file := filepath.Join(root, rel, "package.json") + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + var pkg map[string]any + if err := json.Unmarshal(raw, &pkg); err != nil { + t.Fatal(err) + } + delete(pkg, "packageManager") + delete(pkg, "engines") + delete(pkg, "devDependencies") + deps := map[string]any{} + if current, ok := pkg["dependencies"].(map[string]any); ok { + for key, value := range current { + if strings.HasPrefix(key, "@desktop/") || strings.HasPrefix(key, "@studio/") { + deps[key] = value + } + } + } + pkg["dependencies"] = deps + if internal { + command := `node -e "console.log('MEMBER:` + pkg["name"].(string) + `')"` + pkg["scripts"] = map[string]string{"dev": command, "build": command} + } + out, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, out, 0o644); err != nil { + t.Fatal(err) + } + } + rewrite(".", false) + for _, name := range []string{"desktop", "studio"} { + rewrite("apps/"+name, false) + for _, member := range []string{"apps/electron", "apps/ui", "packages/preload"} { + rewrite("apps/"+name+"/"+member, true) + } + } + install := exec.Command(pnpm, "install", "--offline", "--no-frozen-lockfile") + install.Dir = root + if out, err := install.CombinedOutput(); err != nil { + t.Fatalf("install: %v\n%s", err, out) + } + for _, name := range []string{"desktop", "studio"} { + for _, task := range []string{"dev", "build"} { + cmd := exec.Command(pnpm, "run", task) + cmd.Dir = filepath.Join(root, "apps", name) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", name, task, err, out) + } + text := string(out) + preload := strings.Index(text, "MEMBER:@"+name+"/preload") + for _, member := range []string{"electron", "ui"} { + if index := strings.Index(text, "MEMBER:@"+name+"/"+member); preload < 0 || index <= preload { + t.Fatalf("preload did not finish first: %s", out) + } + } + other := "studio" + if name == "studio" { + other = "desktop" + } + if strings.Contains(text, "MEMBER:@"+other+"/") { + t.Fatalf("started another application: %s", out) + } + } + } + lock, err := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"desktop", "studio"} { + for _, member := range []string{"apps/electron", "apps/ui", "packages/preload"} { + if !strings.Contains(string(lock), "apps/"+name+"/"+member+":") { + t.Errorf("missing lockfile importer for %s/%s", name, member) + } + } + } +} + +func TestElectronPreservesRootSettings(t *testing.T) { + for _, policy := range []string{"", "allowBuilds:\n electron: false\n esbuild: true\n", "onlyBuiltDependencies: [esbuild]\n"} { + t.Run(policy, func(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "demo") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + before := "# user settings\npackages: [apps/*]\nminimumReleaseAge: 1440\n" + policy + if err := os.WriteFile(filepath.Join(root, "pnpm-workspace.yaml"), []byte(before), 0o644); err != nil { + t.Fatal(err) + } + npmrc := "registry=https://registry.npmjs.org/\n" + if err := os.WriteFile(filepath.Join(root, ".npmrc"), []byte(npmrc), 0o644); err != nil { + t.Fatal(err) + } + if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + if cfg["minimumReleaseAge"] != 1440 || !strings.Contains(string(raw), "# user settings") { + t.Fatalf("lost user config: %s", raw) + } + if policy == "" && cfg["allowBuilds"].(map[string]any)["electron"] != true { + t.Fatal("missing default build policy") + } + if strings.HasPrefix(policy, "allowBuilds") && cfg["allowBuilds"].(map[string]any)["electron"] != false { + t.Fatal("overwrote explicit denial") + } + if strings.HasPrefix(policy, "onlyBuilt") && cfg["allowBuilds"] != nil { + t.Fatal("mixed old and new build policies") + } + after, _ := os.ReadFile(filepath.Join(root, ".npmrc")) + if string(after) != npmrc { + t.Fatal("changed user registry") + } + }) + } +} + +func TestElectronRejectsOtherManagerWithoutPartialWrites(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "demo") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + pkg := `{"private":true,"packageManager":"npm@11.0.0"}` + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(pkg), 0o644); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(root, "one.manifest.json")) + if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err == nil { + t.Fatal("unsupported manager accepted") + } + after, _ := os.ReadFile(filepath.Join(root, "one.manifest.json")) + if string(before) != string(after) { + t.Fatal("changed manifest on failure") + } + if _, err := os.Stat(filepath.Join(root, "apps/desktop")); !os.IsNotExist(err) { + t.Fatal("partial project left after failure") + } + after, _ = os.ReadFile(filepath.Join(root, "package.json")) + if string(after) != pkg { + t.Fatal("overwrote root manager") + } +} + +func TestElectronRejectsNormalizedNameCollision(t *testing.T) { + s := newCreationService(t) + root := filepath.Join(t.TempDir(), "demo") + if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { + t.Fatal(err) + } + if err := addLanguageProject(t, s, root, "electron-app", "DesktopApp"); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) + if err := addLanguageProject(t, s, root, "electron-app", "desktop-app"); err == nil { + t.Fatal("duplicate normalized scope accepted") + } + after, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) + if string(before) != string(after) { + t.Fatal("failed add changed workspace") + } + if _, err := os.Stat(filepath.Join(root, "apps/desktop-app")); !os.IsNotExist(err) { + t.Fatal("failed add left partial project") + } +} diff --git a/packages/cli/internal/modules/creation/json_fields.go b/packages/cli/internal/modules/creation/json_fields.go index 317cd3f7..77def376 100644 --- a/packages/cli/internal/modules/creation/json_fields.go +++ b/packages/cli/internal/modules/creation/json_fields.go @@ -3,8 +3,9 @@ package creation import ( "bytes" "encoding/json" - "fmt" "sort" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // marshalJSONValue retains shell operators and version ranges as readable JSON @@ -46,12 +47,12 @@ type jsonField struct { func updateJSONField(raw []byte, key string, value json.RawMessage) ([]byte, error) { if !json.Valid(raw) || value != nil && !json.Valid(value) { - return nil, fmt.Errorf("invalid JSON while updating %q", key) + return nil, i18n.Errorf("creation.json_invalid", key) } decoder := json.NewDecoder(bytes.NewReader(raw)) token, _ := decoder.Token() if token != json.Delim('{') { - return nil, fmt.Errorf("JSON document must be an object") + return nil, i18n.Errorf("creation.json_object") } openEnd := int(decoder.InputOffset()) var fields []jsonField @@ -67,7 +68,7 @@ func updateJSONField(raw []byte, key string, value json.RawMessage) ([]byte, err } fieldKey := token.(string) if seen[fieldKey] { - return nil, fmt.Errorf("duplicate JSON field %q", fieldKey) + return nil, i18n.Errorf("creation.json_duplicate", fieldKey) } seen[fieldKey] = true var fieldValue json.RawMessage diff --git a/packages/cli/internal/modules/creation/languages.go b/packages/cli/internal/modules/creation/languages.go index 21c8918f..72efde78 100644 --- a/packages/cli/internal/modules/creation/languages.go +++ b/packages/cli/internal/modules/creation/languages.go @@ -3,15 +3,16 @@ package creation import ( "bytes" "encoding/json" - "fmt" "path" "path/filepath" "strings" + "gopkg.in/yaml.v3" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/gowork" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" - "gopkg.in/yaml.v3" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) func planLanguages(p *fsutil.FilePlan, m *workspace.Manifest) error { @@ -25,7 +26,11 @@ func planLanguages(p *fsutil.FilePlan, m *workspace.Manifest) error { case "go": goDirs = append(goDirs, rel) case "node": - nodeDirs = append(nodeDirs, rel) + dirs, err := workspace.NodeProjectPackageDirs(p.Root, rel, p.Read) + if err != nil { + return err + } + nodeDirs = append(nodeDirs, dirs...) } } if len(goDirs) > 0 { @@ -67,7 +72,7 @@ func nodePackageManager(p *fsutil.FilePlan) (string, error) { continue } if manager != "" && manager != lock.manager { - return "", fmt.Errorf("%s conflicts with package manager %s", lock.file, manager) + return "", i18n.Errorf("creation.package_manager_conflict", lock.file, manager) } manager = lock.manager } @@ -78,7 +83,7 @@ func nodePackageManager(p *fsutil.FilePlan) (string, error) { case "pnpm", "npm", "yarn", "bun": return manager, nil default: - return "", fmt.Errorf("unsupported package manager %q", manager) + return "", i18n.Errorf("workspace.package_manager_unsupported", manager) } } @@ -94,7 +99,7 @@ func configureNodePackage(p *fsutil.FilePlan, dir, name, manager string) error { return err } if pkg == nil { - return fmt.Errorf("project package.json must be an object") + return i18n.Errorf("creation.project_package_object") } updates := make(map[string]json.RawMessage) updates["name"], _ = marshalJSONValue(name) @@ -173,7 +178,7 @@ func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) return err } if pkg == nil { - return fmt.Errorf("root package.json must be an object") + return i18n.Errorf("creation.root_package_object") } updates := make(map[string]json.RawMessage) if _, ok := pkg["private"]; !ok { @@ -195,10 +200,10 @@ func planNodeWorkspace(p *fsutil.FilePlan, m *workspace.Manifest, dirs []string) if b := pkg["workspaces"]; len(b) > 0 { if err := json.Unmarshal(b, &patterns); err != nil { if err := json.Unmarshal(b, &object); err != nil || object == nil { - return fmt.Errorf("invalid package.json workspaces") + return i18n.Errorf("creation.workspaces_invalid") } if err := json.Unmarshal(object["packages"], &patterns); err != nil { - return fmt.Errorf("invalid package.json workspaces.packages: %w", err) + return i18n.Errorf("creation.workspace_packages_invalid", err) } } } @@ -235,10 +240,10 @@ func includeProjects(patterns, dirs []string) ([]string, error) { glob := strings.TrimPrefix(strings.TrimPrefix(pattern, "!"), "./") match, err := path.Match(glob, dir) if negative && (err != nil || strings.ContainsAny(glob, "{}()") || strings.Contains(glob, "**")) { - return nil, fmt.Errorf("cannot safely add %s with workspace exclusion %q; update that pattern first", dir, pattern) + return nil, i18n.Errorf("creation.workspace_exclusion", dir, pattern) } if match && negative { - return nil, fmt.Errorf("project %s is excluded by workspace pattern %q", dir, pattern) + return nil, i18n.Errorf("creation.project_excluded", dir, pattern) } covered = covered || match } @@ -263,14 +268,37 @@ func planPNPMWorkspace(p *fsutil.FilePlan, dirs []string) error { return err } if len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode { - return fmt.Errorf("pnpm-workspace.yaml must contain a mapping") + return i18n.Errorf("creation.pnpm_mapping") } root := doc.Content[0] + hasBuildPolicy := false + for i := 0; i < len(root.Content); i += 2 { + switch root.Content[i].Value { + case "allowBuilds", "onlyBuiltDependencies", "onlyBuiltDependenciesFile", "ignoredBuiltDependencies", "neverBuiltDependencies", "ignoreScripts", "ignoreDepScripts", "dangerouslyAllowAllBuilds", "strictDepBuilds": + hasBuildPolicy = true + } + } + // Existing policies are authoritative, including explicit denials and + // pnpm 10 settings. New configurations use the same template defaults as + // a freshly created workspace. + addedBuildPolicy := !hasBuildPolicy + if addedBuildPolicy { + var defaults yaml.Node + if err := yaml.Unmarshal([]byte(pnpmWorkspaceContent), &defaults); err != nil { + return err + } + fields := defaults.Content[0].Content + for i := 0; i < len(fields); i += 2 { + if fields[i].Value == "allowBuilds" { + root.Content = append(root.Content, fields[i], fields[i+1]) + } + } + } var packages *yaml.Node for i := 0; i < len(root.Content); i += 2 { if root.Content[i].Value == "packages" { if packages != nil { - return fmt.Errorf("duplicate packages key in pnpm-workspace.yaml") + return i18n.Errorf("creation.pnpm_duplicate") } packages = root.Content[i+1] } @@ -281,7 +309,7 @@ func planPNPMWorkspace(p *fsutil.FilePlan, dirs []string) error { } var patterns []string if packages.Kind != yaml.SequenceNode { - return fmt.Errorf("pnpm-workspace.yaml packages must be an explicit sequence") + return i18n.Errorf("creation.pnpm_sequence") } if err := packages.Decode(&patterns); err != nil { return err @@ -294,6 +322,8 @@ func planPNPMWorkspace(p *fsutil.FilePlan, dirs []string) error { for _, dir := range updated[len(patterns):] { packages.Content = append(packages.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: dir}) } + } + if len(updated) != len(patterns) || addedBuildPolicy { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) @@ -306,3 +336,38 @@ func planPNPMWorkspace(p *fsutil.FilePlan, dirs []string) error { } return p.Set(WorkspaceFilename, raw, 0o644) } + +// Prevent filter commands from matching a different project after names are +// normalized (for example DesktopApp and desktop-app use the same npm scope). +func validateNodePackageNames(p *fsutil.FilePlan, m *workspace.Manifest) error { + names := map[string]string{} + for _, project := range m.Projects { + if project.Toolchain != "node" { + continue + } + dirs, err := workspace.NodeProjectPackageDirs(p.Root, project.RelativeDir, p.Read) + if err != nil { + return err + } + for _, dir := range dirs { + raw, err := p.Read(path.Join(dir, "package.json")) + if err != nil { + return err + } + var pkg struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &pkg); err != nil { + return err + } + if pkg.Name == "" { + continue + } + if previous, exists := names[pkg.Name]; exists && previous != dir { + return i18n.Errorf("creation.duplicate_package", pkg.Name, previous, dir) + } + names[pkg.Name] = dir + } + } + return nil +} diff --git a/packages/cli/internal/modules/creation/languages_test.go b/packages/cli/internal/modules/creation/languages_test.go index f7cb11b9..3cc1bdab 100644 --- a/packages/cli/internal/modules/creation/languages_test.go +++ b/packages/cli/internal/modules/creation/languages_test.go @@ -21,7 +21,7 @@ func addLanguageProject(t *testing.T, s *Service, root, id, name string) error { } for i := range r.Templates { if r.Templates[i].ID == id { - _, err := s.AddProject(context.Background(), root, ProjectInput{Template: &r.Templates[i], Name: name, DeferDeployment: true}) + _, err := s.AddProject(context.Background(), root, ProjectInput{Template: &r.Templates[i], Name: name}) return err } } diff --git a/packages/cli/internal/modules/creation/package_format_test.go b/packages/cli/internal/modules/creation/package_format_test.go index 3ad21f4a..df07a849 100644 --- a/packages/cli/internal/modules/creation/package_format_test.go +++ b/packages/cli/internal/modules/creation/package_format_test.go @@ -69,7 +69,7 @@ func TestGeneratedNodeProjectsPassFormatting(t *testing.T) { if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "format-check"}); err != nil { t.Fatal(err) } - result, err := s.AddProject(context.Background(), root, ProjectInput{Template: &entry, Name: "sample", DeferDeployment: true}) + result, err := s.AddProject(context.Background(), root, ProjectInput{Template: &entry, Name: "sample"}) if err != nil { t.Fatal(err) } diff --git a/packages/cli/internal/modules/creation/preset.go b/packages/cli/internal/modules/creation/preset.go index eb64a225..dd78e957 100644 --- a/packages/cli/internal/modules/creation/preset.go +++ b/packages/cli/internal/modules/creation/preset.go @@ -6,6 +6,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/preset" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) // PresetResult is what `one create --preset` reports back. The shape is @@ -62,7 +63,7 @@ func ApplyPreset(ctx context.Context, projectRoot string, resolved preset.Resolv id, err := preset.Encode(resolved.Spec) if err != nil { - return out, fmt.Errorf("creation: encode canonical preset id: %w", err) + return out, i18n.Errorf("creation.preset_encode", err) } out.PresetID = id @@ -93,10 +94,8 @@ func ApplyPreset(ctx context.Context, projectRoot string, resolved preset.Resolv } res, applyErr := materializeProject(ctx, projectRoot, ProjectInput{ - Template: it.Template, - Name: name, - Deploy: it.Deploy, - Container: it.Container, + Template: it.Template, + Name: name, }) if applyErr != nil { return out, applyErr @@ -128,19 +127,6 @@ func projectNameFor(tpl *template.Template, occurrence int) string { return fmt.Sprintf("%s-%d", base, occurrence+1) } -// SummarizeDeploys flattens the result's project deploy backends into a -// {"kustomize": N, "vercel": M, ...} count map for the envelope. Empty -// deploy backends (templates with no deploy domain) are excluded. -func (r PresetResult) SummarizeDeploys() map[string]int { - out := map[string]int{} - for _, p := range r.Projects { - if p.DeployBackend != "" { - out[p.DeployBackend]++ - } - } - return out -} - // EffectiveEnvProvider returns the workspace env provider that should // be written to the manifest. preset's `e` segment wins; absent // segment falls through to "" so the caller can layer the diff --git a/packages/cli/internal/modules/creation/project.go b/packages/cli/internal/modules/creation/project.go index da102bee..4ea39345 100644 --- a/packages/cli/internal/modules/creation/project.go +++ b/packages/cli/internal/modules/creation/project.go @@ -2,10 +2,8 @@ package creation import ( "context" - "fmt" "os" "path/filepath" - "slices" "strings" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" @@ -14,6 +12,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" 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/pkg/toolchain" ) @@ -26,26 +25,6 @@ type ProjectInput struct { // Name is the subproject name (validated for the IsValidProjectName // regex by the caller). Name string - // Deploy is the optional override; "" means use Template.Defaults["deploy"]. - // Must already be in Template.Compat["deploy"]; we re-check defensively. - Deploy string - // Container is the optional container backend for kustomize deploys. - // Empty means use Docker Hub as the preset default when kustomize is - // selected. - Container string - // DeferDeployment leaves deployment and image-registry selections unset. - // Ordinary `one add` uses this so the first `one deploy` owns the choice. - DeferDeployment bool - // ConfigureDeployTargets preserves the interactive add path that writes - // kustomize target metadata. Presets and first-deploy setup leave it false. - ConfigureDeployTargets bool - DeployTarget DeploymentTarget -} - -type DeploymentTarget struct { - Bucket string - Namespace string - KustomizationPath string } // ProjectResult is the transport-neutral outcome of materialising a Template, @@ -57,11 +36,7 @@ type ProjectResult struct { TemplateID string Toolchain string PackageManager string - // DeployBackend is the effective deploy backend that ended up on - // the manifest for this project (e.g. "kustomize", "vercel"). "" - // when the template carries no deploy domain. - DeployBackend string - Warnings []string + Warnings []string } // materializeProject renders the template into projectRoot, upserts the @@ -98,11 +73,11 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput return ProjectResult{}, err } if in.Template == nil { - return ProjectResult{}, fmt.Errorf("creation: template is required") + return ProjectResult{}, i18n.Errorf("creation.template_required") } if !workspace.IsValidProjectName(in.Name) { return ProjectResult{}, cliErrors.New(cliErrors.INVALID_NAME, - fmt.Sprintf("项目名称格式不合法: %q", in.Name)) + i18n.Tf("add.name_invalid", in.Name)) } entry := in.Template @@ -120,7 +95,7 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput if exists, _ := dirNonEmpty(targetDir); exists { return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, - fmt.Sprintf("项目目录已存在: %s", targetDir)). + i18n.Tf("creation.directory_exists", targetDir)). WithContext(map[string]any{ "subproject_name": in.Name, "target_path": targetDir, @@ -158,6 +133,16 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput relDir = filepath.Join(categoryDir, in.Name) } + if entry.Toolchain == "node" { + dirs, err := workspace.NodeProjectPackageDirs(projectRoot, relDir, files.Read) + if err != nil { + return ProjectResult{}, err + } + if len(dirs) > 1 && packageManager != "pnpm" { + return ProjectResult{}, i18n.Errorf("creation.composite_requires_pnpm", entry.ID, packageManager) + } + } + manifestPM := manifestPackageManagerFor(string(entry.Toolchain), packageManager) newProject := workspace.ManifestProject{ Name: in.Name, @@ -179,7 +164,7 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput } for _, p := range manifest.Projects { if p.RelativeDir == newProject.RelativeDir || p.Name == in.Name { - return ProjectResult{}, fmt.Errorf("project %s is already registered", in.Name) + return ProjectResult{}, i18n.Errorf("creation.project_registered", in.Name) } } manifest.Projects = append(manifest.Projects, newProject) @@ -190,6 +175,9 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput if err := configureNodePackage(files, relDir, vars["projectNameKebabCase"], packageManager); err != nil { return ProjectResult{}, err } + if err := validateNodePackageNames(files, manifest); err != nil { + return ProjectResult{}, err + } } if hk, err := files.Read(workspace.HooksConfigFilename); err != nil { return ProjectResult{}, err @@ -226,40 +214,6 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput } registered = true - deployBackend := "" - if !in.DeferDeployment { - deployBackend, err = pickDeployBackend(entry, in.Deploy) - if err != nil { - return ProjectResult{}, err - } - } - - if err := applyTemplateDefaults(projectRoot, entry, in.Name, deployBackend, !in.DeferDeployment); err != nil { - return ProjectResult{}, err - } - - effectiveDeploy := deployBackend - if !in.DeferDeployment && effectiveDeploy == "" && entry.Defaults != nil { - effectiveDeploy = entry.Defaults["deploy"] - } - if effectiveDeploy == "kustomize" && entry.Defaults != nil && entry.Defaults["container"] != "" { - containerBackend := strings.TrimSpace(in.Container) - if containerBackend == "" { - containerBackend = "dockerhub" - } - if err := workspace.SetProjectContainerKind(projectRoot, in.Name, containerBackend); err != nil { - return ProjectResult{}, err - } - } - - if !in.DeferDeployment { - if err := applyDeployTargets( - projectRoot, entry, in.Name, deployBackend, in.ConfigureDeployTargets, in.DeployTarget, - ); err != nil { - return ProjectResult{}, err - } - } - addManifest, _ := workspace.ReadManifest(projectRoot) var thisSub *workspace.ManifestProject for i := range addManifest.Projects { @@ -272,8 +226,6 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput if err := syncProject(syncProjectOptions{ ProjectRoot: projectRoot, TargetDir: targetDir, - ProjectName: in.Name, - TemplateID: entry.ID, Toolchain: toolchain.Toolchain(entry.Toolchain), PackageManager: toolchain.PackageManager(packageManager), Selected: addSelected, @@ -291,224 +243,10 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput TemplateID: entry.ID, Toolchain: string(entry.Toolchain), PackageManager: manifestPM, - DeployBackend: effectiveDeploy, Warnings: warningMessages(warnings), }, nil } -// ConfigureProjectDeployment applies a compatible deployment choice to an -// existing project and generates only the deployment/container artifacts. -// It is the mutation step used by the first-deploy wizard; project source is -// never re-rendered. -func configureProjectDeployment(ctx context.Context, projectRoot string, tpl *template.Template, projectName, backend string) error { - if tpl == nil { - return fmt.Errorf("creation: template is required") - } - backend, err := pickDeployBackend(tpl, backend) - if err != nil { - return err - } - if backend == "" { - return cliErrors.New(cliErrors.BACKEND_NOT_ENABLED, "deployment target is required") - } - if err := applyTemplateDefaults(projectRoot, tpl, projectName, backend, true); err != nil { - return err - } - if backend == "kustomize" { - containerKind := "docker" - if tpl.Defaults != nil && strings.TrimSpace(tpl.Defaults["container"]) != "" { - containerKind = strings.TrimSpace(tpl.Defaults["container"]) - } - if err := workspace.SetProjectContainerKind(projectRoot, projectName, containerKind); err != nil { - return err - } - } - if err := applyDeployTargets(projectRoot, tpl, projectName, backend, false, DeploymentTarget{}); err != nil { - return err - } - - m, err := workspace.ReadManifest(projectRoot) - if err != nil { - return err - } - var project *workspace.ManifestProject - for i := range m.Projects { - if m.Projects[i].Name == projectName { - project = &m.Projects[i] - break - } - } - if project == nil { - return cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, "project not found: "+projectName) - } - targetDir := filepath.Join(projectRoot, filepath.FromSlash(project.RelativeDir)) - packageManager := project.PackageManager - if packageManager == "" { - packageManager = defaultPackageManagerFor(project.Toolchain) - } - selected := workspace.SelectionForProject(m, project) - if err := syncProject(syncProjectOptions{ - ProjectRoot: projectRoot, - TargetDir: targetDir, - ProjectName: project.Name, - TemplateID: project.TemplateID, - Toolchain: toolchain.Toolchain(project.Toolchain), - PackageManager: toolchain.PackageManager(packageManager), - Selected: selected, - }); err != nil { - return err - } - _ = ctx - return nil -} - -// pickDeployBackend resolves the deploy backend for a subproject. -// Precedence: explicit override (e.g. addcmd's --deploy-provider or -// preset's `@` segment) > interactive multi-option prompt > -// template default ("" means "let applyTemplateDefaults use the -// registry default"). -func pickDeployBackend(tpl *template.Template, flagDeploy string) (string, error) { - if tpl == nil { - return "", nil - } - flagDeploy = strings.TrimSpace(flagDeploy) - compat := []string{} - if tpl.Compat != nil { - compat = append(compat, tpl.Compat["deploy"]...) - } - if flagDeploy != "" { - if len(compat) > 0 && !slices.Contains(compat, flagDeploy) { - return "", cliErrors.New(cliErrors.PROFILE_BACKEND_INVALID, - fmt.Sprintf("deploy %q 不在模板 %s 的 compat.deploy 列表里(合法值:%v)", flagDeploy, tpl.ID, compat)) - } - return flagDeploy, nil - } - return "", nil -} - -// applyTemplateDefaults writes the template's per-domain backend -// selections into the manifest, honouring the deploy override and -// skipping the container default when a non-kustomize deploy is in use -// (same precedent as the pre-extraction version in addcmd). -func applyTemplateDefaults(projectRoot string, tpl *template.Template, subprojectName, deployOverride string, includeDeployment bool) error { - if tpl == nil || len(tpl.Defaults) == 0 { - return nil - } - for domain, backend := range tpl.Defaults { - if domain == "" || backend == "" { - continue - } - if domain == "deploy" && strings.TrimSpace(deployOverride) != "" { - backend = deployOverride - } - if !includeDeployment && (domain == "deploy" || domain == "container") { - continue - } - if domain == "container" && strings.TrimSpace(deployOverride) != "" && deployOverride != "kustomize" { - continue - } - id := domain + "/" + backend - switch domain { - case "container", "deploy": - if _, err := workspace.SetPerProjectSelection(projectRoot, domain, id, subprojectName); err != nil { - return err - } - case "env": - m, err := workspace.ReadManifest(projectRoot) - if err != nil { - return err - } - if workspace.EnvBackend(m) != "" { - continue - } - if _, err := workspace.SetWorkspaceSelection(projectRoot, domain, id); err != nil { - return err - } - case "ci", "dev": - continue - } - } - return nil -} - -// promptDeployTargets fills deploy-target metadata that lives in the -// manifest (k8s namespace, kustomization path, S3 bucket). Mirrors the -// pre-extraction version; interactive=false skips the prompts but -// still applies deterministic defaults (workspace.id → S3 bucket etc). -func applyDeployTargets( - projectRoot string, - tpl *template.Template, - subprojectName string, - deployOverride string, - configure bool, - target DeploymentTarget, -) error { - if tpl == nil { - return nil - } - defaults := tpl.Defaults - if len(defaults) == 0 && strings.TrimSpace(deployOverride) == "" { - return nil - } - - effectiveBackend := strings.TrimSpace(deployOverride) - if effectiveBackend == "" { - effectiveBackend = defaults["deploy"] - } - - if workspace.IsS3CompatibleDeploy(effectiveBackend) { - m, err := workspace.ReadManifest(projectRoot) - if err != nil { - return err - } - if bucket := workspace.ExplicitDeployBucketForProject(m, subprojectName); bucket != "" { - return nil - } - if projectID := workspace.WorkspaceID(m); projectID != "" { - return workspace.SetProjectDeployBucket(projectRoot, subprojectName, projectID) - } - if configure && strings.TrimSpace(target.Bucket) != "" { - if err := workspace.SetProjectDeployBucket(projectRoot, subprojectName, strings.TrimSpace(target.Bucket)); err != nil { - return err - } - } - } - - if !configure { - return nil - } - - if backend := effectiveBackend; backend == "kustomize" { - m, err := workspace.ReadManifest(projectRoot) - if err != nil { - return err - } - explicitNamespace := workspace.ExplicitDeployNamespace(m) - defaultNamespace := workspace.WorkspaceID(m) - hasNamespace := explicitNamespace != "" || defaultNamespace != "" - hasPath := workspace.DeployKustomizationPath(m) != "" - if hasNamespace && hasPath { - return nil - } - ns := explicitNamespace - if ns == "" && defaultNamespace == "" { - ns = strings.TrimSpace(target.Namespace) - } - path := workspace.DeployKustomizationPath(m) - if path == "" { - path = strings.TrimSpace(target.KustomizationPath) - if path == "" { - path = "kustomize/overlays/prod" - } - } - if err := workspace.SetWorkspaceDeployTarget(projectRoot, ns, path); err != nil { - return err - } - } - - return nil -} - // warningMessages flattens compat warnings to strings; empty / nil is // returned untouched so callers may apply omitempty. func warningMessages(ws []template.Warning) []string { @@ -526,18 +264,18 @@ func warningMessages(ws []template.Warning) []string { func parseLocalTemplateID(repo string) (string, error) { if !strings.HasPrefix(repo, template.LocalTemplatePrefix) { return "", cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, - fmt.Sprintf("Phase 4a 仅支持 local: 前缀模板;待 phase 5 支持远程下载: %s", repo)) + i18n.Tf("creation.remote_template_unsupported", repo)) } id := strings.TrimSpace(strings.TrimPrefix(repo, template.LocalTemplatePrefix)) id = strings.TrimLeft(id, "/") if id == "" { return "", cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, - fmt.Sprintf("本地模板配置无效:%s。请使用 local: 格式。", repo)) + i18n.Tf("creation.template_invalid", repo)) } for _, seg := range strings.FieldsFunc(id, func(r rune) bool { return r == '/' || r == '\\' }) { if seg == ".." { return "", cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, - fmt.Sprintf("本地模板配置无效:%s。不允许使用 \"..\" 路径。", repo)) + i18n.Tf("creation.template_parent_path", repo)) } } return id, nil @@ -553,7 +291,7 @@ func categoryDirFor(category string) (string, error) { return "packages", nil default: return "", cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, - fmt.Sprintf("未知模板分类: %s", category)) + i18n.Tf("creation.category_unknown", category)) } } diff --git a/packages/cli/internal/modules/creation/service.go b/packages/cli/internal/modules/creation/service.go index 9edda2f9..00b216d3 100644 --- a/packages/cli/internal/modules/creation/service.go +++ b/packages/cli/internal/modules/creation/service.go @@ -5,12 +5,10 @@ package creation import ( "context" "errors" - "fmt" "os" "path/filepath" "strings" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/hooks" @@ -18,6 +16,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/preset" 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" ) type Service struct { @@ -35,7 +34,7 @@ func NewService( observers ...WorkspaceObserver, ) (*Service, error) { if environments == nil { - return nil, errors.New("creation: environment service is required") + return nil, errors.New(i18n.T("creation.environment_required")) } var observer WorkspaceObserver if len(observers) > 0 { @@ -90,13 +89,13 @@ func validateWorkspaceTarget(targetDir, displayPath string) error { if !empty { return cliErrors.New( cliErrors.EXISTING_TARGET_NOT_EMPTY, - fmt.Sprintf("目标目录 %s 已存在且非空。请删除目录后重试,或换一个目标位置。", displayPath), + i18n.Tf("creation.target_not_empty", displayPath), ).WithContext(map[string]any{"target_path": targetDir, "display_path": displayPath}) } if enclosing := enclosingWorkspace(targetDir); enclosing != "" { return cliErrors.New( cliErrors.WORKSPACE_NESTED_FORBIDDEN, - fmt.Sprintf("拒绝在已存在的工作区里创建新工作区:%s 已经是一个 one workspace。", enclosing), + i18n.Tf("creation.nested_workspace", enclosing), ).WithContext(map[string]any{ "target_path": targetDir, "enclosing_workspace": enclosing, }) @@ -114,7 +113,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo if !workspace.IsValidProjectName(input.Name) { return result, cliErrors.New( cliErrors.INVALID_NAME, - fmt.Sprintf("工作区名称格式不合法: %q", input.Name), + i18n.Tf("creation.workspace_name_invalid", input.Name), ) } if result.EnvBackend == "" { @@ -123,7 +122,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo if result.EnvBackend != workspace.EnvBackendDotenv && result.EnvBackend != workspace.EnvBackendInfisical { return result, cliErrors.New( cliErrors.BACKEND_ID_UNKNOWN, - fmt.Sprintf("--env-provider 值无效: %q(合法值: dotenv / infisical)", result.EnvBackend), + i18n.Tf("env.provider_invalid", result.EnvBackend), ) } displayPath := input.DisplayPath @@ -192,14 +191,14 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo } if err != nil { return result, cliErrors.New(cliErrors.ONE_CLI_ERROR, - fmt.Sprintf("workspace was created but mise configuration is incomplete; fix the reported error and run one configure mise: %v", err)). + i18n.Tf("creation.mise_warning", err)). WithContext(map[string]any{"workspace": input.TargetDir, "partial_state": "mise_configuration_incomplete"}) } if pkg, err := workspace.ReadPackageJSON(input.TargetDir); err == nil && pkg != nil { result.PackageManager, _, _ = strings.Cut(pkg.PackageManager, "@") } if err := initGitRepo(input.TargetDir); err != nil { - result.HooksWarn = fmt.Errorf("Git initialization failed; initialize Git and run one configure hooks: %w", err) + result.HooksWarn = i18n.Errorf("creation.git_failed", err) } else { install, err := hooks.PlanInstall(ctx, input.TargetDir, "", false) if err == nil { @@ -231,16 +230,6 @@ func (s *Service) AddProject( }, nil } -func (s *Service) ConfigureProjectDeployment( - ctx context.Context, - projectRoot string, - tpl *template.Template, - projectName string, - backend string, -) error { - return configureProjectDeployment(ctx, projectRoot, tpl, projectName, backend) -} - func enclosingWorkspace(targetDir string) string { current := filepath.Clean(targetDir) for { diff --git a/packages/cli/internal/modules/creation/service_test.go b/packages/cli/internal/modules/creation/service_test.go index 982fd77b..ec040ae9 100644 --- a/packages/cli/internal/modules/creation/service_test.go +++ b/packages/cli/internal/modules/creation/service_test.go @@ -7,7 +7,6 @@ import ( "path/filepath" "testing" - configureapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/configure" catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" @@ -81,7 +80,7 @@ func TestServiceOwnsWorkspaceAndProjectCreation(t *testing.T) { t.Fatal("react-spa template is absent") } added, err := service.AddProject(context.Background(), target, ProjectInput{ - Template: selected, Name: "web", DeferDeployment: true, + Template: selected, Name: "web", }) if err != nil { t.Fatal(err) @@ -128,14 +127,7 @@ func TestCreateWorkspaceRechecksTargetBeforeMutation(t *testing.T) { func newCreationService(t *testing.T, observers ...WorkspaceObserver) *Service { t.Helper() backendCatalog := catalog.Builtin() - profiles, err := configureapp.NewProfileService( - backendCatalog, - configureapp.LocalProfileRepository{}, - ) - if err != nil { - t.Fatal(err) - } - environments, err := environmentmodule.NewService(backendCatalog, profiles) + environments, err := environmentmodule.NewService(backendCatalog) if err != nil { t.Fatal(err) } diff --git a/packages/cli/internal/modules/creation/workspace_content.go b/packages/cli/internal/modules/creation/workspace_content.go index 1c115589..362615d7 100644 --- a/packages/cli/internal/modules/creation/workspace_content.go +++ b/packages/cli/internal/modules/creation/workspace_content.go @@ -19,6 +19,8 @@ allowBuilds: '@scarf/scarf': false '@swc/core': true electron: true + # The Electron template uses NSIS, not the optional Squirrel installer. + electron-winstaller: false esbuild: true unrs-resolver: true ` diff --git a/packages/cli/internal/modules/dependencies/pnpm_test.go b/packages/cli/internal/modules/dependencies/pnpm_test.go new file mode 100644 index 00000000..c7c31e32 --- /dev/null +++ b/packages/cli/internal/modules/dependencies/pnpm_test.go @@ -0,0 +1,209 @@ +package dependencies + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" +) + +func TestPNPMDevelopmentReusesManualInstallAndUpdatesNewProjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("native pnpm executable test") + } + pnpm, err := exec.LookPath("pnpm") + if err != nil { + t.Skip("pnpm is not installed") + } + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node is not installed") + } + root, home := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + t.Setenv("CI", "true") + versionCmd := exec.Command(pnpm, "--version") + versionCmd.Dir = root + version, err := versionCmd.Output() + if err != nil { + t.Fatalf("pnpm --version: %v", err) + } + if !supportsPNPMDependencyCheck(string(version)) { + t.Skipf("pnpm %s lacks dependency verification", version) + } + write(t, root, "package.json", `{"name":"workspace","private":true}`) + write(t, root, "pnpm-workspace.yaml", "packages:\n - apps/*\n - packages/*\n - apps/web/apps/ui\n") + write(t, root, "packages/shared/package.json", `{"name":"shared","version":"1.0.0"}`) + write(t, root, "apps/web/package.json", `{"name":"web","workspaces":["apps/ui"]}`) + write(t, root, "apps/web/apps/ui/package.json", `{"name":"@web/ui","dependencies":{"shared":"workspace:*"}}`) + manual := exec.Command(pnpm, "install", "--no-frozen-lockfile", "--offline") + manual.Dir = root + if data, err := manual.CombinedOutput(); err != nil { + t.Fatalf("manual install: %v\n%s", err, data) + } + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{ + project("web", "apps/web", "node"), project("shared", "packages/shared", "node"), + }}, Runtime: runtimeport.Builtin, Development: true} + installs := 0 + s := Service{Run: func(ctx context.Context, c runtimeport.Command, out, errOut io.Writer) error { + if c.Argv[0] == "pnpm" { + c.Argv[0] = pnpm + if c.Argv[1] == "install" { + installs++ + c.Argv = append(c.Argv, "--offline") // The fixture uses only workspace dependencies. + } + } + return runCommand(ctx, c, out, errOut) + }} + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 0 { + t.Fatal("reinstalled after a successful manual pnpm install") + } + write(t, root, "apps/ccc/package.json", `{"name":"ccc","dependencies":{"shared":"workspace:*"}}`) + in.Manifest.Projects = append(in.Manifest.Projects, project("ccc", "apps/ccc", "node")) + before, _ := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) + strict := in + strict.Development = false + if err := s.Prepare(context.Background(), strict); err == nil { + t.Fatal("strict preparation accepted an outdated lockfile") + } + after, _ := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) + if !bytes.Equal(before, after) { + t.Fatal("strict preparation rewrote the lockfile") + } + installs = 0 + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatalf("new project: %v", err) + } + after, _ = os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) + if !strings.Contains(string(after), "apps/ccc:") { + t.Fatalf("new project missing from lockfile: %s", after) + } + if !nodeInstalled(in) { + t.Fatal("new project's dependency was not linked") + } + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 1 { + t.Fatalf("expected one install after adding a project, got %d", installs) + } + // The native check must notice a manifest edit even if links still exist. + write(t, root, "apps/ccc/package.json", `{"name":"ccc","dependencies":{"shared":"workspace:^"}}`) + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 2 { + t.Fatal("a changed dependency specifier was ignored") + } + // No manifest entry is added for the UI package. Both native validation + // and the strict-build fingerprint must still notice edits inside it. + if err := s.Prepare(context.Background(), strict); err != nil { + t.Fatal(err) + } + if err := s.Prepare(context.Background(), strict); err != nil { + t.Fatal(err) + } + before, _ = os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) + write(t, root, "apps/web/apps/ui/package.json", `{"name":"@web/ui","dependencies":{"shared":"workspace:^"}}`) + if err := s.Prepare(context.Background(), strict); err == nil { + t.Fatal("cached build ignored an internal manifest edit") + } + after, _ = os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) + if !bytes.Equal(before, after) { + t.Fatal("strict build changed the lockfile") + } + installs = 0 + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 1 { + t.Fatal("development ignored an internal manifest edit") + } + if err := s.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if installs != 1 { + t.Fatal("reinstalled an unchanged composite project") + } +} + +func TestNodeInstallFailureIsOnlyPrintedOnce(t *testing.T) { + t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) + for _, locale := range []string{"en-US", "zh-CN"} { + _ = i18n.Init(locale) + root := t.TempDir() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) + write(t, root, "apps/web/package.json", `{"dependencies":{"missing":"1.0.0"}}`) + diagnostic := "ERR_PNPM_FETCH_500: registry unavailable\n" + calls := 0 + service := Service{Run: func(_ context.Context, c runtimeport.Command, out, errOut io.Writer) error { + if c.Argv[1] == "--version" { + _, _ = io.WriteString(out, "12.3.4\n") + return nil + } + calls++ + if strings.Join(c.Argv, " ") != "pnpm install --no-frozen-lockfile" { + t.Fatalf("unexpected install: %v", c.Argv) + } + _, _ = io.WriteString(errOut, diagnostic) + return errors.New("exit status 1") + }} + var log bytes.Buffer + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("web", "apps/web", "node")}}, Development: true, Log: &log} + err := service.Prepare(context.Background(), in) + failure, ok := err.(*output.Error) + if !ok { + t.Fatalf("expected structured error, got %v", err) + } + if strings.Count(log.String()+err.Error(), strings.TrimSpace(diagnostic)) != 1 { + t.Fatalf("duplicated diagnostic: %s\n%v", log.String(), err) + } + if failure.Context["stderr"] != diagnostic || calls != 1 { + t.Fatalf("lost diagnostic or retried failed install: %+v, calls=%d", failure.Context, calls) + } + in.Log = nil + err = service.Prepare(context.Background(), in) + if err == nil || !strings.Contains(err.Error(), strings.TrimSpace(diagnostic)) { + t.Fatalf("discarded diagnostic when logs were disabled: %v", err) + } + } +} + +func TestPNPMValidationCancellationDoesNotStartInstall(t *testing.T) { + root := t.TempDir() + write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) + write(t, root, "apps/web/package.json", `{}`) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := Service{Run: func(_ context.Context, c runtimeport.Command, out, _ io.Writer) error { + if c.Argv[1] == "--version" { + _, _ = io.WriteString(out, "12.3.4\n") + return nil + } + if c.Argv[1] != "--config.verify-deps-before-run=error" { + t.Fatalf("installation started after cancellation: %v", c.Argv) + } + cancel() + return ctx.Err() + }} + err := s.Prepare(ctx, Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("web", "apps/web", "node")}}, Development: true}) + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} diff --git a/packages/cli/internal/modules/dependencies/service.go b/packages/cli/internal/modules/dependencies/service.go index d3b01e5d..3d108e47 100644 --- a/packages/cli/internal/modules/dependencies/service.go +++ b/packages/cli/internal/modules/dependencies/service.go @@ -1,4 +1,4 @@ -// Package dependencies prepares application dependencies before development. +// Package dependencies prepares application dependencies before development and builds. // Tool installation belongs to the runtime provider; run remains a plain runner. package dependencies @@ -14,16 +14,20 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" + "strconv" "strings" "syscall" + "gopkg.in/yaml.v3" + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" 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/output" platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" - "gopkg.in/yaml.v3" ) type Runner func(context.Context, runtimeport.Command, io.Writer, io.Writer) error @@ -37,8 +41,14 @@ type Input struct { Root string Manifest *workspace.Manifest Project string + // Projects selects an explicit set, including projects without a dev command. + // nil preserves the development selection used by existing callers. + Projects []string Runtime string Log io.Writer + // Development allows pnpm to synchronize the lockfile and reuse a manual + // install after pnpm has verified the workspace dependency state. + Development bool } func (s Service) Prepare(ctx context.Context, in Input) error { @@ -47,9 +57,17 @@ func (s Service) Prepare(ctx context.Context, in Input) error { if in.Log == nil { in.Log = io.Discard } + selected := map[string]bool{} + for _, name := range in.Projects { + selected[name] = true + } var nodes, goProjects []workspace.ManifestProject for _, p := range in.Manifest.Projects { - if (in.Project != "" && p.Name != in.Project) || strings.TrimSpace(workspace.ProjectDev(in.Manifest, p.Name)) == "" { + if in.Projects != nil { + if !selected[p.Name] { + continue + } + } else if (in.Project != "" && p.Name != in.Project) || strings.TrimSpace(workspace.ProjectDev(in.Manifest, p.Name)) == "" { continue } switch p.Toolchain { @@ -79,7 +97,7 @@ func (s Service) run(ctx context.Context, in Input, dir string, args []string, e command := runtimeport.Command{Directory: dir, Argv: args, Env: env} if in.Runtime == runtimeport.Mise { if s.Provider == nil { - return fmt.Errorf("mise provider is required") + return i18n.Errorf("dependencies.mise_required") } var err error command, err = s.Provider.Prepare(ctx, command) @@ -88,7 +106,7 @@ func (s Service) run(ctx context.Context, in Input, dir string, args []string, e } } if len(command.Argv) == 0 { - return fmt.Errorf("dependency runtime returned an empty command") + return i18n.Errorf("dependencies.command_empty") } run := s.Run if run == nil { @@ -132,7 +150,7 @@ func (s Service) prepareGo(ctx context.Context, in Input, p workspace.ManifestPr activeInfo, activeErr := os.Stat(active) wantInfo, wantErr := os.Stat(want) if activeErr != nil || wantErr != nil || !os.SameFile(activeInfo, wantInfo) { - return fmt.Errorf("%s uses external GOWORK=%s; use %s or GOWORK=off explicitly", p.Name, active, want) + return i18n.Errorf("dependencies.external_gowork", p.Name, active, want) } } // Resolve the root, not the go.work file itself: relative use paths @@ -145,7 +163,7 @@ func (s Service) prepareGo(ctx context.Context, in Input, p workspace.ManifestPr return err } defer unlock() - fmt.Fprintf(in.Log, "[one] %s: preparing Go dependencies\n", p.Name) + fmt.Fprintf(in.Log, i18n.T("dependencies.go_preparing"), p.Name) // In workspace mode, package loading uses the actual Go build graph and // writes workspace sums as needed. Expanding `all` can fetch historical // versions of local members; only use it for a standalone module. @@ -163,7 +181,7 @@ func (s Service) prepareGo(ctx context.Context, in Input, p workspace.ManifestPr if err := s.run(ctx, in, dir, args, env, out, io.MultiWriter(in.Log, &detail)); err != nil { failure := preparationError(p.Name, dir, strings.Join(args, " "), err, detail.String()) if args[1] == "list" { - return failure.WithRemediation(output.Remediation{Action: "repair-go-module", Hint: "Inspect the Go error. If module declarations need repair, run tidy explicitly.", Command: "one run " + p.Name + " -- go mod tidy"}) + return failure.WithRemediation(output.Remediation{Action: "repair-go-module", Hint: i18n.T("dependencies.go_repair_hint"), Command: "one run " + p.Name + " -- go mod tidy"}) } return failure } @@ -181,7 +199,7 @@ func (s Service) downloadModule(ctx context.Context, in Input, p workspace.Manif return err } if module == nil { - return fmt.Errorf("missing go.mod for %s", p.Name) + return i18n.Errorf("dependencies.go_mod_missing", p.Name) } sums, err := files.Read("go.sum") if err != nil { @@ -218,8 +236,8 @@ func (s Service) downloadModule(ctx context.Context, in Input, p workspace.Manif return err } if !bytes.Equal(module, after) { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, "Go dependencies require changes to "+p.Name+"/go.mod; automatic preparation preserved the original file").WithRemediation(output.Remediation{ - Action: "repair-go-module", Command: "one run " + p.Name + " -- go mod tidy", Hint: "Review the module declarations and run tidy explicitly.", + return cliErrors.New(cliErrors.ONE_CLI_ERROR, i18n.Tf("dependencies.go_changes_required", p.Name)).WithRemediation(output.Remediation{ + Action: "repair-go-module", Command: "one run " + p.Name + " -- go mod tidy", Hint: i18n.T("dependencies.go_tidy_hint"), }) } afterSums, err := os.ReadFile(sumName) @@ -241,13 +259,32 @@ func (s Service) prepareNode(ctx context.Context, in Input, fallback string) err return err } defer unlock() - manager := PackageManager(in.Root, fallback) + manager, err := workspace.ResolvePackageManager(in.Root, fallback) + if err != nil { + return err + } var versions bytes.Buffer for _, args := range [][]string{{manager, "--version"}, {"node", "--version"}} { if err := s.run(ctx, in, in.Root, args, os.Environ(), &versions, in.Log); err != nil { return preparationError("Node workspace", in.Root, strings.Join(args, " "), err, versions.String()) } } + // Ask pnpm itself to validate installed dependencies. Its check covers the + // workspace structure, manifest changes, lockfile, and installation settings. + // The error policy never installs; stale or unsupported state falls through + // to the ordinary install below. This also recognizes manual `pnpm install`. + nativeCheck := in.Development && manager == "pnpm" && supportsPNPMDependencyCheck(versions.String()) + if nativeCheck { + if nodeInstalled(in) { + args := []string{"pnpm", "--config.verify-deps-before-run=error", "exec", "node", "--eval", ""} + if err := s.run(ctx, in, in.Root, args, os.Environ(), io.Discard, io.Discard); err == nil { + return nil + } + } + if err := ctx.Err(); err != nil { + return err + } + } before, err := nodeFingerprint(in, versions.String()) if err != nil { return err @@ -258,14 +295,27 @@ func (s Service) prepareNode(ctx context.Context, in Input, fallback string) err } marker := filepath.Join(cache, "one", "dependencies", fmt.Sprintf("%x", sha256.Sum256([]byte(in.Root)))) previous, _ := os.ReadFile(marker) - if string(previous) == before && nodeInstalled(in) { + if !nativeCheck && string(previous) == before && nodeInstalled(in) { return nil } args := NodeInstallCommand(in.Root, manager) - fmt.Fprintf(in.Log, "[one] Preparing Node workspace dependencies: %s\n", strings.Join(args, " ")) + if in.Development && manager == "pnpm" { + args = []string{manager, "install", "--no-frozen-lockfile"} + } + fmt.Fprintf(in.Log, i18n.T("dependencies.node_preparing"), strings.Join(args, " ")) var detail bytes.Buffer if err := s.run(ctx, in, in.Root, args, os.Environ(), in.Log, io.MultiWriter(in.Log, &detail)); err != nil { - return preparationError("Node workspace", in.Root, strings.Join(args, " "), err, detail.String()) + failure := preparationError("Node workspace", in.Root, strings.Join(args, " "), err, detail.String()) + if in.Log != io.Discard { + // stderr has already been streamed. Keep it in structured context + // without printing the same diagnostic again in the final summary. + failure.Message = i18n.Tf("dependencies.failed_summary", "Node workspace", strings.Join(args, " "), err) + failure.Context["stderr"] = detail.String() + } + return failure + } + if nativeCheck { + return nil // pnpm owns the installed-state cache for this path. } after, err := nodeFingerprint(in, versions.String()) if err != nil { @@ -274,6 +324,23 @@ func (s Service) prepareNode(ctx context.Context, in Input, fallback string) err return fsutil.WriteAtomic(marker, []byte(after), 0o600) } +// verifyDepsBeforeRun=error is supported by the pnpm versions we can verify +// without parsing private lockfile/state formats. Older/unknown versions keep +// the regular installation path and One's fingerprint cache. +func supportsPNPMDependencyCheck(versions string) bool { + fields := strings.Fields(versions) + if len(fields) == 0 { + return false + } + parts := strings.Split(strings.TrimPrefix(fields[0], "v"), ".") + if len(parts) != 3 { + return false + } + major, majorErr := strconv.Atoi(parts[0]) + minor, minorErr := strconv.Atoi(parts[1]) + return majorErr == nil && minorErr == nil && (major > 10 || major == 10 && minor >= 14) +} + func nodeInstalled(in Input) bool { if !workspace.ProjectDependenciesInstalled(in.Root, in.Root, "node") { return false @@ -288,12 +355,14 @@ func nodeInstalled(in Input) bool { func nodeFingerprint(in Input, versions string) (string, error) { h := sha256.New() - fmt.Fprintln(h, versions) + fmt.Fprintln(h, "node-dependencies-v3", in.Development, in.Runtime, versions) paths := []string{"package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", ".npmrc", ".yarnrc.yml"} - for _, p := range in.Manifest.Projects { - if p.Toolchain == "node" { - paths = append(paths, filepath.Join(p.RelativeDir, "package.json")) - } + dirs, err := nodePackageDirs(in) + if err != nil { + return "", err + } + for _, dir := range dirs { + paths = append(paths, filepath.Join(dir, "package.json"), filepath.Join(dir, ".npmrc")) } for _, path := range paths { b, err := os.ReadFile(filepath.Join(in.Root, path)) @@ -306,23 +375,6 @@ func nodeFingerprint(in Input, versions string) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -func PackageManager(root, fallback string) string { - manager := strings.TrimSpace(fallback) - if pkg, err := workspace.ReadPackageJSON(root); err == nil && pkg != nil && pkg.PackageManager != "" { - manager = pkg.PackageManager - } - manager, _, _ = strings.Cut(manager, "@") - if manager != "" { - return manager - } - for _, item := range []struct{ file, manager string }{{"bun.lock", "bun"}, {"bun.lockb", "bun"}, {"yarn.lock", "yarn"}, {"package-lock.json", "npm"}} { - if exists(filepath.Join(root, item.file)) { - return item.manager - } - } - return "pnpm" -} - func NodeInstallCommand(root, manager string) []string { switch manager { case "pnpm": @@ -410,5 +462,29 @@ func preparationError(project, dir, command string, err error, detail string) *o if errors.As(err, &exit) { context["exit_code"] = exit.ExitCode() } - return cliErrors.New(code, fmt.Sprintf("%s dependency preparation failed (%s): %v\n%s", project, command, err, strings.TrimSpace(detail))).WithContext(context) + return cliErrors.New(code, i18n.Tf("dependencies.failed", project, command, err, strings.TrimSpace(detail))).WithContext(context) +} + +// Internal packages belong to the same install even when only their parent is +// registered in one.manifest.json or selected for development/building. +func nodePackageDirs(in Input) ([]string, error) { + seen := map[string]bool{} + var dirs []string + for _, p := range in.Manifest.Projects { + if p.Toolchain != "node" { + continue + } + members, err := workspace.NodeProjectPackageDirs(in.Root, p.RelativeDir, nil) + if err != nil { + return nil, err + } + for _, member := range members { + if !seen[member] { + seen[member] = true + dirs = append(dirs, member) + } + } + } + sort.Strings(dirs) + return dirs, nil } diff --git a/packages/cli/internal/modules/dependencies/service_test.go b/packages/cli/internal/modules/dependencies/service_test.go index 324d7b74..69cce0d8 100644 --- a/packages/cli/internal/modules/dependencies/service_test.go +++ b/packages/cli/internal/modules/dependencies/service_test.go @@ -346,3 +346,31 @@ func TestPNPMEnvironmentDocumentDoesNotPretendDependenciesAreLocked(t *testing.T } } } + +// Build selection must include libraries with no development process. +func TestExplicitProjectsPrepareGoLibrariesWithoutDev(t *testing.T) { + root := t.TempDir() + write(t, root, "go.work", "go 1.25.0\nuse ./packages/lib\n") + p := workspace.ManifestProject{Name: "lib", RelativeDir: "packages/lib", Toolchain: "go"} + write(t, root, "packages/lib/go.mod", "module example.com/lib\ngo 1.25.0\n") + calls := []string{} + service := Service{Run: func(_ context.Context, cmd runtimeport.Command, out, _ io.Writer) error { + calls = append(calls, strings.Join(cmd.Argv, " ")) + if strings.Join(cmd.Argv, " ") == "go env GOWORK" { + fmt.Fprintln(out, filepath.Join(root, "go.work")) + } + return nil + }} + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{p}}, Projects: []string{"lib"}, Runtime: runtimeport.Builtin} + if err := service.Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + if len(calls) != 2 || calls[1] != "go list -mod=readonly -buildvcs=false -deps ./..." { + t.Fatal(calls) + } + calls = nil + in.Projects = []string{} + if err := service.Prepare(context.Background(), in); err != nil || len(calls) != 0 { + t.Fatalf("empty selection: %v %v", calls, err) + } +} diff --git a/packages/cli/internal/modules/development/process/ops.go b/packages/cli/internal/modules/development/process/ops.go index 8af31194..69ba5894 100644 --- a/packages/cli/internal/modules/development/process/ops.go +++ b/packages/cli/internal/modules/development/process/ops.go @@ -1,17 +1,7 @@ package processorch -// ops.go exposes Start as the package-level entry point for `one dev`. -// Behaviour summary: -// - Reads the workspace manifest at /one.manifest.json -// - Walks projects[] and gathers each project's domains.dev.command -// - Wraps each command as `one run -p -- ` so -// per-project secrets injection still happens -// - Runs the built-in supervisor (supervisor_unix.go on Unix, stub on -// other platforms) -// -// Procfile.dev is no longer written or read. External Procfile runners -// (overmind / hivemind / foreman / honcho) are no longer probed — -// `one dev` is self-contained. +// Start resolves manifest commands and delegates execution to the shared task +// session. one run remains the owner of runtime and per-project environment. import ( "context" @@ -22,9 +12,18 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/taskrun" runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" ) +// ProcEntry is a manifest task before terminal execution is selected. +type ProcEntry struct { + Name, Cmd string + Argv []string +} + // StartInput addresses Start. type StartInput struct { Runtime string @@ -32,7 +31,10 @@ type StartInput struct { DryRun bool // Process, when non-empty, restricts the supervisor to a single // project entry by manifest project name. - Process string + Process string + Processes []string + UI taskrun.Mode + KeepGoing bool } // StartResult is the Start envelope. @@ -42,9 +44,10 @@ type StartResult struct { Argv []string `json:"argv"` // Runner is always "builtin" now — kept for forward-compat with // JSON consumers that switch on it. - Runner string `json:"runner"` - DryRun bool `json:"dry_run"` - Process string `json:"process,omitempty"` + Runner string `json:"runner"` + DryRun bool `json:"dry_run"` + Process string `json:"process,omitempty"` + Processes []string `json:"processes,omitempty"` } // Start launches the built-in supervisor against the projects declared @@ -60,13 +63,20 @@ func Start(ctx context.Context, in StartInput) (*StartResult, error) { if err != nil { return nil, err } - entries := buildEntriesFromManifest(m, in.Process) + selectors := in.Processes + if len(selectors) == 0 && in.Process != "" { + selectors = []string{in.Process} + } + entries, err := EntriesForProjects(m, selectors) + if err != nil { + return nil, err + } if len(entries) == 0 { return nil, cliErrors.New(cliErrors.SUBPROJECT_NOT_FOUND, selectorErrorMessage(m, in.Process)) } - if in.Runtime == runtimeport.Mise { + { binary, err := os.Executable() if err != nil { return nil, err @@ -99,10 +109,23 @@ func Start(ctx context.Context, in StartInput) (*StartResult, error) { if in.Runtime == runtimeport.Mise { res.Runtime = runtimeport.Mise } + if len(selectors) == 1 { + res.Process = selectors[0] + } else if len(selectors) > 1 { + res.Processes = selectors + } if in.DryRun { return res, nil } - if err := runBuiltin(ctx, in.ProjectRoot, entries, BuiltinOpts{Out: os.Stdout}); err != nil { + tasks := make([]taskrun.Task, 0, len(entries)) + for _, e := range entries { + tasks = append(tasks, taskrun.Task{Name: e.Name, Directory: in.ProjectRoot, Argv: e.Argv}) + } + log := os.Stdout + if output.IsStructured() { + log = os.Stderr + } + if _, err := taskrun.Run(ctx, tasks, taskrun.Options{Mode: in.UI, Title: "dev", Development: true, KeepGoing: in.KeepGoing, Output: log}); err != nil { return nil, err } return res, nil @@ -140,12 +163,31 @@ func buildEntriesFromManifest(m *workspace.Manifest, selector string) []ProcEntr // filter at all. func selectorErrorMessage(m *workspace.Manifest, selector string) string { if selector != "" { - return fmt.Sprintf("项目 %q 在 one.manifest.json 里没有声明 dev 命令。"+ - "重新 `one add %s` 让模板写入,或手工编辑 projects[].domains.dev.command。", selector, selector) + return i18n.Tf("dev.project_command_missing", selector, selector) } if m == nil || len(m.Projects) == 0 { - return "工作区里还没有任何项目。先 `one add