From 8d23c52baf96bd9a39b62991852d18c08ed0a71a Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Thu, 1 Oct 2026 15:00:55 +0800 Subject: [PATCH 1/2] feat(env): add explicit Infisical binding in CLI and Dashboard --- apps/dashboard/src/api/workspace.ts | 28 +- .../global-variables/GlobalVariables.test.tsx | 86 ++-- .../global-variables/GlobalVariables.tsx | 33 +- .../global-variables/LocationPicker.tsx | 22 +- .../InfisicalBindingDialog.tsx | 395 ++++++++++++++++++ .../features/secrets/SecretsManager.test.tsx | 305 ++++++++++++-- .../src/features/secrets/SecretsManager.tsx | 54 ++- .../WorkspaceEnvironmentSettings.tsx | 178 +++----- .../WorkspaceSettingsDialog.tsx | 29 +- apps/dashboard/src/locales/en-US.json | 29 +- apps/dashboard/src/locales/zh-CN.json | 29 +- apps/dashboard/src/router/routes.test.tsx | 2 +- apps/docs/content/docs/en/create.md | 2 +- apps/docs/content/docs/en/env-vars.md | 19 +- apps/docs/content/docs/en/error-codes.md | 2 +- apps/docs/content/docs/en/login.md | 6 +- apps/docs/content/docs/en/manifest.md | 2 +- apps/docs/content/docs/zh/create.md | 2 +- apps/docs/content/docs/zh/env-vars.md | 19 +- apps/docs/content/docs/zh/error-codes.md | 4 +- apps/docs/content/docs/zh/login.md | 6 +- apps/docs/content/docs/zh/manifest.md | 2 +- apps/docs/content/tutorials/en/env-vars.mdx | 16 +- .../content/tutorials/en/infisical-login.mdx | 2 +- .../tutorials/en/json-output-error-codes.mdx | 2 +- apps/docs/content/tutorials/zh/env-vars.mdx | 16 +- .../content/tutorials/zh/infisical-login.mdx | 2 +- .../internal/adapters/env/infisical/init.go | 20 +- .../adapters/env/infisical/shared_location.go | 18 +- .../internal/application/manifest/service.go | 6 + .../modules/environment/binding_test.go | 110 ++++- .../internal/modules/environment/global.go | 3 + .../modules/environment/operations.go | 48 +-- .../internal/modules/environment/result.go | 7 + .../internal/modules/environment/service.go | 2 +- .../internal/modules/environment/target.go | 91 +++- .../cli/internal/platform/errors/codes.go | 4 +- .../internal/platform/i18n/locales/en-US.json | 28 +- .../internal/platform/i18n/locales/zh-CN.json | 28 +- .../cli/internal/transport/cobra/env/bind.go | 46 ++ .../internal/transport/cobra/env/bind_test.go | 156 +++++++ .../internal/transport/cobra/env/global.go | 46 +- .../transport/cobra/env/global_test.go | 189 +++++++++ .../internal/transport/cobra/env/render.go | 32 ++ .../transport/cobra/env/render_test.go | 31 ++ .../cli/internal/transport/cobra/env/set.go | 6 +- .../http/handlers_workspace_bind_test.go | 62 +++ .../http/handlers_workspace_mutate.go | 35 ++ .../transport/http/handlers_workspaces.go | 2 + .../cli/internal/transport/http/responses.go | 2 + packages/cli/testdata/reference/help/env.txt | 2 +- .../cli/testdata/reference/help/env_bind.txt | 16 +- .../cli/testdata/reference/help/env_set.txt | 2 +- packages/cli/tests/e2e/e2e_helpers_test.go | 17 + .../tests/e2e/snapshot_e2e_tty_unix_test.go | 1 + .../cli/tests/e2e/snapshot_e2e_ux_test.go | 10 + 56 files changed, 1879 insertions(+), 433 deletions(-) create mode 100644 apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx create mode 100644 packages/cli/internal/transport/cobra/env/bind.go create mode 100644 packages/cli/internal/transport/cobra/env/bind_test.go create mode 100644 packages/cli/internal/transport/cobra/env/global_test.go create mode 100644 packages/cli/internal/transport/http/handlers_workspace_bind_test.go diff --git a/apps/dashboard/src/api/workspace.ts b/apps/dashboard/src/api/workspace.ts index 14e65847..d2e5f52b 100644 --- a/apps/dashboard/src/api/workspace.ts +++ b/apps/dashboard/src/api/workspace.ts @@ -34,25 +34,21 @@ export async function getWorkspaceEnvironment( return http.get(workspaceEnvironmentKey(entryId, environment)); } -export interface EnvironmentInitialization extends WorkspaceEnvironmentSettings { - binding?: { - project_id: string; - project_name: string; - created: boolean; - requested_name?: string; - }; +export interface WorkspaceBinding { + project_id: string; + project_name?: string; + created: boolean; + requested_name?: string; + environments: string[]; } -export async function initializeWorkspaceEnvironmentBackend( +export function bindWorkspaceEnvironment( entryId: string | undefined, - environment: string, - project?: string, -): Promise { - const search = new URLSearchParams({ env: environment }); - if (project) search.set("project", project); - return http.post( - `${workspaceBasePath(entryId)}/environment/backend/initialize?${search.toString()}`, - ); + input: { revision: string; create: boolean; projectId?: string }, +): Promise { + return http.post(`${workspaceBasePath(entryId)}/environment/bind`, input, { + timeout: 120000, + }); } function projectBasePath(project: string, entryId?: string): string { diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx index febe1a3d..a8ece2f0 100644 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx +++ b/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx @@ -110,23 +110,40 @@ describe("global credential browsing", () => { }); }); +async function openBinding(user: ReturnType, existing = false) { + await user.click( + await screen.findByRole("button", { + name: i18n.t(existing ? "binding.change" : "binding.globalTitle"), + }), + ); + return within(await screen.findByRole("dialog", { name: i18n.t("binding.globalTitle") })); +} +async function chooseExisting(user: ReturnType) { + await user.click(screen.getByRole("combobox", { name: i18n.t("binding.method") })); + await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); +} describe("shared credential setup", () => { - it("initializes the default location only on click and then opens the credential list", async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - vi.mocked(api.initializeGlobalLocation).mockImplementation(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - await screen.findByRole("button", { name: "Initialize default location" }); - expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); - expect(api.getGlobalListing).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Initialize default location" })); - await screen.findByText("OSS_AK"); - expect(api.initializeGlobalLocation).toHaveBeenCalledTimes(1); - }); - it("creates and selects a project without changing storage until Save location", async () => { + it.each(["en-US", "zh-CN"])( + "binds the default storage only after confirmation in %s", + async (locale) => { + await i18n.changeLanguage(locale); + vi.mocked(api.getLocation).mockResolvedValue({ location: null }); + vi.mocked(api.initializeGlobalLocation).mockImplementation(async () => { + vi.mocked(api.getLocation).mockResolvedValue({ location }); + return { location }; + }); + mount(); + const user = userEvent.setup(); + const dialog = await openBinding(user); + expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); + expect(api.getGlobalListing).not.toHaveBeenCalled(); + await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); + await screen.findByText("OSS_AK"); + expect(api.initializeGlobalLocation).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }, + ); + it("creates and selects a custom project, then binds only on confirmation", async () => { vi.mocked(api.getLocation).mockResolvedValue({ location: null }); const created = { id: "team", @@ -142,19 +159,17 @@ describe("shared credential setup", () => { }); mount(); const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: "New project" })); + await openBinding(user); + await chooseExisting(user); + await user.click(screen.getByRole("button", { name: "New project" })); await user.type(screen.getByLabelText("Project name"), "Team"); await user.click(screen.getByRole("button", { name: "Create and select" })); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull()); expect(api.createRemoteProject).toHaveBeenCalledWith("Team"); - expect(screen.getByRole("combobox", { name: "Storage project" }).textContent).toContain("Team"); - expect( - screen.getByRole("combobox", { name: "Default browsing environment" }).textContent, - ).toContain("dev"); expect(api.bindLocation).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Save location" })); - await waitFor(() => expect(api.bindLocation).toHaveBeenCalledWith("team", "dev")); + await user.click(screen.getByRole("button", { name: i18n.t("binding.bindExisting") })); await screen.findByText("OSS_AK"); + expect(api.bindLocation).toHaveBeenCalledWith("team", "dev"); }); it("keeps a failed creation editable and leaves the existing location alone", async () => { vi.mocked(api.createRemoteProject).mockRejectedValue( @@ -162,28 +177,31 @@ describe("shared credential setup", () => { ); mount(); const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: "Default storage location" })); - expect(screen.queryByRole("button", { name: "Initialize default location" })).toBeNull(); + await openBinding(user, true); await user.click(screen.getByRole("button", { name: "New project" })); await user.type(screen.getByLabelText("Project name"), "Team"); await user.click(screen.getByRole("button", { name: "Create and select" })); await screen.findByText("No permission to create projects"); - expect(screen.getByRole("dialog")).toBeTruthy(); expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("Team"); expect(api.bindLocation).not.toHaveBeenCalled(); expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); }); - it("shows default setup failure without falling through to an empty credential list", async () => { + it("retains default setup failures and retries without an empty credential list", async () => { vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - vi.mocked(api.initializeGlobalLocation).mockRejectedValue( - new Error("Default environment is missing"), - ); + vi.mocked(api.initializeGlobalLocation) + .mockRejectedValueOnce(new Error("Default environment is missing")) + .mockImplementationOnce(async () => { + vi.mocked(api.getLocation).mockResolvedValue({ location }); + return { location }; + }); mount(); const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: "Initialize default location" })); - await screen.findByText("Default environment is missing"); + const dialog = await openBinding(user); + await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); + await dialog.findByText("Default environment is missing"); expect(api.getGlobalListing).not.toHaveBeenCalled(); - expect(screen.getByRole("button", { name: "New project" })).toBeTruthy(); + await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); + await screen.findByText("OSS_AK"); }); }); diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx index 9dfe0995..ae9104a7 100644 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx +++ b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx @@ -35,7 +35,7 @@ import { sessionKey, type GlobalLocation, } from "@/api/session"; -import { LocationPicker } from "./LocationPicker"; +import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; @@ -113,10 +113,10 @@ export function GlobalVariables() { title={t("global.title")} description={t("global.description")} actions={ - signedIn && current && !configure && !mismatched ? ( + signedIn && current ? ( ) : undefined } @@ -159,22 +159,29 @@ export function GlobalVariables() { ) : ( <> {mismatched && {t("global.mismatch")}} - {!current || configure || mismatched ? ( - { - await location.mutate(); - setConfigure(false); - }} - onCancel={current && !mismatched ? () => setConfigure(false) : undefined} - /> + {!current || mismatched ? ( + + + + + ) : ( )} + )} diff --git a/apps/dashboard/src/features/global-variables/LocationPicker.tsx b/apps/dashboard/src/features/global-variables/LocationPicker.tsx index 4c1651db..f5074d62 100644 --- a/apps/dashboard/src/features/global-variables/LocationPicker.tsx +++ b/apps/dashboard/src/features/global-variables/LocationPicker.tsx @@ -2,7 +2,7 @@ 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 { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR, { useSWRConfig } from "swr"; import { @@ -39,10 +39,12 @@ export function LocationPicker({ initial, onSaved, onCancel, + onBusyChange, }: { initial?: GlobalLocation; - onSaved: () => Promise; + onSaved: (location?: GlobalLocation) => Promise; onCancel?: () => void; + onBusyChange?: (busy: boolean) => void; }) { const { t } = useTranslation(); const { mutate } = useSWRConfig(); @@ -58,6 +60,9 @@ export function LocationPicker({ const [name, setName] = useState(""); const [createError, setCreateError] = useState(""); const [discard, setDiscard] = useState(false); + useEffect(() => { + onBusyChange?.(busy); + }, [busy, onBusyChange]); function closeCreation() { if (busy) return; if (name.trim()) setDiscard(true); @@ -69,9 +74,10 @@ export function LocationPicker({ setBusy(true); setError(""); try { - if (useDefault) await initializeGlobalLocation(); - else await bindLocation(project, environment); - await onSaved(); + const result = useDefault + ? await initializeGlobalLocation() + : await bindLocation(project, environment); + await onSaved(result.location); } catch (e) { setError(message(e)); // Setup may have created the remote project before a later step failed. @@ -114,7 +120,7 @@ export function LocationPicker({ title={t("global.location")} description={t("global.locationHint")} /> - {!initial ? ( + {!initial && !onBusyChange ? (

{t("global.defaultLocation")}

@@ -214,7 +220,9 @@ export function LocationPicker({ onClick={() => void save(false)} > {busy ? : } - {busy ? t("global.saving") : t("global.saveLocation")} + {busy + ? t("global.saving") + : t(onBusyChange ? "binding.bindExisting" : "global.saveLocation")} {onCancel ? ( +
+ ) : ( + <> +
+
+
{t("session.account")}
+
{account.email || account.userId}
+
+
+
{t("session.organization")}
+
+ {account.organizationId || t("binding.organizationRequired")} +
+
+
+
{t("session.site")}
+
{account.siteUrl}
+
+
+ {initialId && ( +

{t("binding.current", { name: currentName })}

+ )} +
+ + +
+ {isExisting && !workspace ? ( + { + await finish(location?.projectName || t("global.title")); + }} + onCancel={() => onOpenChange(false)} + onBusyChange={setBusy} + /> + ) : ( + <> + {isExisting ? ( +
+ + + {detail.data && !detail.data.environments.some((e) => e.slug === "dev") && ( + {t("binding.devRequired")} + )} + {projects.data?.length === 0 && ( +

{t("binding.noProjects")}

+ )} +
+ ) : null} +
+

+ {t("binding.target", { + name: isExisting + ? detail.data?.name || project + : targetName || t("session.loading"), + })} +

+

{t("binding.defaultEnvironment")}

+

+ {t(workspace ? "binding.workspaceStorage" : "binding.globalStorage")} +

+
+ {settings.error || + overview.error || + (isExisting && (projects.error || detail.error)) ? ( + + {message(settings.error || overview.error || projects.error || detail.error)} + + ) : null} + {draft && ( +
+

{t("binding.pendingDraft")}

+
+ {workspaceEntryId && } + +
+
+ )} + {error && ( + { + await Promise.allSettled([settings.mutate(), overview.mutate()]); + setConflict(false); + setError(""); + }} + > + {t("secrets.retry")} + + ) : undefined + } + > + {error} + + )} + + + + + + )} + + )} + + ); +} diff --git a/apps/dashboard/src/features/secrets/SecretsManager.test.tsx b/apps/dashboard/src/features/secrets/SecretsManager.test.tsx index f7624598..faebaa7e 100644 --- a/apps/dashboard/src/features/secrets/SecretsManager.test.tsx +++ b/apps/dashboard/src/features/secrets/SecretsManager.test.tsx @@ -3,34 +3,46 @@ import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { SWRConfig } from "swr"; +import { MemoryRouter } from "react-router-dom"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { SecretsManager } from "@/features/secrets/SecretsManager"; import i18n from "@/lib/i18n"; +import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; const server = setupServer( http.get("http://localhost/api/session", () => - HttpResponse.json({ session: { loggedIn: true, expired: false } }), + HttpResponse.json({ + session: { + loggedIn: true, + expired: false, + siteUrl: "https://secrets.example.com", + userId: "user", + organizationId: "org", + }, + }), ), ); function renderManager() { return render( - new Map(), dedupingInterval: 0 }}> - - , + + new Map(), dedupingInterval: 0 }}> + + + , ); } function mockUnconfiguredStorage() { - const requests = { initializations: 0, saves: 0, lists: 0 }; + const requests = { initializations: 0, bindings: 0, saves: 0, lists: 0 }; server.use( http.get("http://localhost/api/workspaces/demo-entry/secrets", () => { requests.lists++; - if (!requests.initializations) + if (!requests.bindings) return HttpResponse.json( { error: { code: "INFISICAL_NOT_CONFIGURED", message: "Storage is not connected." } }, { status: 409 }, @@ -43,6 +55,69 @@ function mockUnconfiguredStorage() { total: requests.saves, }); }), + http.get("http://localhost/api/workspaces/demo-entry/environment", () => + HttpResponse.json({ + revision: "revision-1", + backend: "", + projectId: "", + siteUrl: "", + environments: ["dev"], + }), + ), + http.get("http://localhost/api/workspaces/demo-entry/overview", () => + HttpResponse.json({ present: true, workspace: { name: "demo" } }), + ), + http.get("http://localhost/api/infisical/projects/existing", () => + HttpResponse.json({ + id: "existing", + name: "Existing project", + orgId: "org", + environments: [{ name: "Development", slug: "dev" }], + }), + ), + http.post( + "http://localhost/api/workspaces/demo-entry/environment/bind", + async ({ request }) => { + const body = (await request.json()) as { + revision: string; + create: boolean; + projectId?: string; + }; + expect(body.revision).toBe("revision-1"); + if (!body.create) expect(body.projectId).toBe("existing"); + requests.bindings++; + return HttpResponse.json({ + project_id: body.projectId || "new", + project_name: body.create ? "demo" : "Existing project", + created: body.create, + environments: ["dev"], + }); + }, + ), + http.get("http://localhost/api/infisical/projects", () => + HttpResponse.json([ + { + id: "existing", + name: "Existing project", + orgId: "org", + environments: [{ name: "Development", slug: "dev" }], + }, + ]), + ), + http.post("http://localhost/api/workspaces/demo-entry/manifest/preview", () => + HttpResponse.json({ + revision: "revision-1", + path: "one.manifest.toml", + before: "version = 2\n", + after: 'version = 2\n[env.infisical]\nprojectId = "existing"\n', + }), + ), + http.put("http://localhost/api/workspaces/demo-entry/manifest", async ({ request }) => { + const body = (await request.json()) as { workspace: { environment: { projectId: string } } }; + expect(body.workspace.environment.projectId).toBe("existing"); + requests.bindings++; + return HttpResponse.json({ revision: "revision-2" }); + }), http.post("http://localhost/api/workspaces/demo-entry/environment/backend/initialize", () => { requests.initializations++; return HttpResponse.json({ @@ -59,7 +134,8 @@ function mockUnconfiguredStorage() { }); }), http.post("http://localhost/api/workspaces/demo-entry/secrets", async ({ request }) => { - expect(requests.initializations).toBe(1); + expect(requests.bindings).toBe(1); + expect(requests.initializations).toBe(0); expect(await request.json()).toEqual({ key: "API_TOKEN", value: "secret-value" }); requests.saves++; return HttpResponse.json({ action: "created", key: "API_TOKEN" }, { status: 201 }); @@ -75,6 +151,7 @@ describe("Infisical secrets manager", () => { }); afterEach(async () => { server.resetHandlers(); + useManifestDraftStore.setState({ drafts: {} }); await i18n.changeLanguage("en-US"); }); afterAll(() => server.close()); @@ -115,6 +192,153 @@ describe("Infisical secrets manager", () => { expect(reveals).toBe(1); }); + it("blocks duplicate binding submissions and keeps the pending dialog open", async () => { + const requests = mockUnconfiguredStorage(); + let complete!: () => void; + let submits = 0; + server.use( + http.post("http://localhost/api/workspaces/demo-entry/environment/bind", async () => { + submits++; + await new Promise((resolve) => { + complete = resolve; + }); + requests.bindings++; + return HttpResponse.json({ + project_id: "new", + project_name: "demo", + created: true, + environments: ["dev"], + }); + }), + ); + renderManager(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); + const confirm = await screen.findByRole("button", { name: i18n.t("binding.createAndBind") }); + await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); + await user.dblClick(confirm); + await waitFor(() => expect(submits).toBe(1)); + expect( + screen.getByRole("button", { name: i18n.t("binding.binding") }).hasAttribute("disabled"), + ).toBe(true); + await user.keyboard("{Escape}"); + expect(screen.getByRole("dialog")).toBeDefined(); + complete(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(submits).toBe(1); + }); + + it("recovers a created project after a revision conflict without creating again", async () => { + const requests = mockUnconfiguredStorage(); + let creates = 0; + let revision = "revision-1"; + server.use( + http.get("http://localhost/api/workspaces/demo-entry/environment", () => + HttpResponse.json({ revision, projectId: "", environments: ["dev"] }), + ), + http.post( + "http://localhost/api/workspaces/demo-entry/environment/bind", + async ({ request }) => { + const body = (await request.json()) as { + create: boolean; + revision: string; + projectId?: string; + }; + if (body.create) { + creates++; + revision = "revision-2"; + return HttpResponse.json( + { + error: { + code: "SERVE_MANIFEST_CONFLICT", + message: "Configuration changed", + context: { + partial_state: "project_created_binding_unsaved", + project_id: "existing", + }, + }, + }, + { status: 409 }, + ); + } + expect(body).toEqual({ create: false, revision: "revision-2", projectId: "existing" }); + requests.bindings++; + return HttpResponse.json({ + project_id: "existing", + project_name: "demo", + created: false, + environments: ["dev"], + }); + }, + ), + ); + renderManager(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); + const create = await screen.findByRole("button", { name: i18n.t("binding.createAndBind") }); + await waitFor(() => expect(create.hasAttribute("disabled")).toBe(false)); + await user.click(create); + await screen.findByText(i18n.t("binding.conflict")); + const bind = screen.getByRole("button", { name: i18n.t("binding.bindExisting") }); + expect(bind.hasAttribute("disabled")).toBe(true); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { name: i18n.t("secrets.retry") }), + ); + await waitFor(() => expect(bind.hasAttribute("disabled")).toBe(false)); + await user.click(bind); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(creates).toBe(1); + expect(requests.bindings).toBe(1); + }); + + it("requires pending configuration to be saved or discarded before binding", async () => { + const requests = mockUnconfiguredStorage(); + useManifestDraftStore.getState().stageWorkspaceSection({ + entryId: "demo-entry", + revision: "revision-1", + section: "environment", + initial: {}, + next: { backend: "infisical", projectId: "existing" }, + labels: {}, + }); + renderManager(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); + await screen.findByText(i18n.t("binding.pendingDraft")); + const confirm = screen.getByRole("button", { name: i18n.t("binding.createAndBind") }); + expect(confirm.hasAttribute("disabled")).toBe(true); + expect(requests.bindings).toBe(0); + await user.click(screen.getByRole("button", { name: i18n.t("manifestDraft.discard") })); + await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); + expect(useManifestDraftStore.getState().drafts["demo-entry"]).toBeUndefined(); + }); + + it("rejects a workspace project without the required dev environment", async () => { + const requests = mockUnconfiguredStorage(); + server.use( + http.get("http://localhost/api/infisical/projects/existing", () => + HttpResponse.json({ + id: "existing", + name: "Existing project", + orgId: "org", + environments: [{ slug: "prod", name: "Production" }], + }), + ), + ); + renderManager(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); + await user.click(screen.getByRole("combobox", { name: i18n.t("binding.method") })); + await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); + await user.click(screen.getByRole("combobox", { name: i18n.t("global.project") })); + await user.click(await screen.findByRole("option", { name: "Existing project" })); + await screen.findByText(i18n.t("binding.devRequired")); + expect( + screen.getByRole("button", { name: i18n.t("binding.bindExisting") }).hasAttribute("disabled"), + ).toBe(true); + expect(requests.bindings).toBe(0); + }); + it("creates a secret in the selected scope without touching a manifest API", async () => { let requestBody: unknown; server.use( @@ -172,31 +396,58 @@ describe("Infisical secrets manager", () => { expect(requests.saves).toBe(0); }); - it("cancels the editor without initializing storage", async () => { + it("disables adding secrets until storage is explicitly connected", async () => { + expect( + screen.getByRole("button", { name: i18n.t("secrets.add") }).hasAttribute("disabled"), + ).toBe(true); await user.click(screen.getByRole("button", { name: i18n.t("secrets.add") })); - await user.click( - within(await screen.findByRole("dialog")).getByRole("button", { - name: i18n.t("form.cancel"), - }), - ); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(screen.queryByRole("dialog")).toBeNull(); expect(requests.initializations).toBe(0); expect(requests.saves).toBe(0); }); - it("rejects an invalid key before initializing storage", async () => { - await user.click(screen.getByRole("button", { name: i18n.t("secrets.add") })); + it("opens and closes the connection dialog without creating storage", async () => { + await user.click(screen.getByRole("button", { name: i18n.t("binding.workspaceTitle") })); const dialog = within(await screen.findByRole("dialog")); - await user.click(dialog.getByLabelText(i18n.t("secrets.key"))); - await user.paste("1INVALID"); - await user.click(dialog.getByRole("button", { name: i18n.t("secrets.save") })); - expect(await dialog.findByText(i18n.t("secrets.invalidKey"))).toBeDefined(); + expect(await dialog.findByRole("combobox", { name: i18n.t("binding.method") })).toBeDefined(); + await user.click(dialog.getAllByRole("button", { name: i18n.t("form.close") })[0]); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(requests.initializations).toBe(0); + expect(requests.bindings).toBe(0); expect(requests.saves).toBe(0); }); - it("initializes storage once before saving a valid secret", async () => { - await user.click(screen.getByRole("button", { name: i18n.t("secrets.add") })); + it("creates and binds storage only after the explicit confirmation", async () => { + await user.click(screen.getByRole("button", { name: i18n.t("binding.workspaceTitle") })); + const dialog = within(await screen.findByRole("dialog")); + const confirm = await dialog.findByRole("button", { name: i18n.t("binding.createAndBind") }); + await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); + expect(requests.bindings).toBe(0); + await user.click(confirm); + await waitFor(() => expect(requests.bindings).toBe(1)); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(requests.initializations).toBe(0); + expect( + screen.getAllByRole("button", { name: i18n.t("secrets.add") })[0].hasAttribute("disabled"), + ).toBe(false); + }); + it("saves secrets only after confirming an explicit binding", async () => { + await user.click(screen.getByRole("button", { name: i18n.t("binding.workspaceTitle") })); + const connection = within(await screen.findByRole("dialog")); + await user.click(await connection.findByRole("combobox", { name: i18n.t("binding.method") })); + await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); + const selector = await connection.findByRole("combobox", { name: i18n.t("global.project") }); + await waitFor(() => expect(selector.hasAttribute("disabled")).toBe(false)); + await user.click(selector); + await user.click(await screen.findByRole("option", { name: "Existing project" })); + expect(requests.bindings).toBe(0); + const confirm = connection.getByRole("button", { name: i18n.t("binding.bindExisting") }); + await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); + await user.click(confirm); + await waitFor(() => expect(requests.bindings).toBe(1)); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + const add = await screen.findAllByRole("button", { name: i18n.t("secrets.add") }); + await user.click(add[0]); const dialog = within(await screen.findByRole("dialog")); await user.click(dialog.getByLabelText(i18n.t("secrets.key"))); await user.paste("API_TOKEN"); @@ -205,7 +456,7 @@ describe("Infisical secrets manager", () => { expect(requests.initializations).toBe(0); await user.click(dialog.getByRole("button", { name: i18n.t("secrets.save") })); await waitFor(() => expect(requests.saves).toBe(1)); - expect(requests.initializations).toBe(1); + expect(requests.initializations).toBe(0); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); }); diff --git a/apps/dashboard/src/features/secrets/SecretsManager.tsx b/apps/dashboard/src/features/secrets/SecretsManager.tsx index 840635b8..18190ad6 100644 --- a/apps/dashboard/src/features/secrets/SecretsManager.tsx +++ b/apps/dashboard/src/features/secrets/SecretsManager.tsx @@ -32,7 +32,7 @@ import { secretsKey, updateSecret, } from "@/api/secrets"; -import { initializeWorkspaceEnvironmentBackend } from "@/api/workspace"; +import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; import { AlertDialog, AlertDialogCancel, @@ -113,7 +113,7 @@ export const SecretsManager: React.FC<{ const [deleteKey, setDeleteKey] = useState(""); const [deleteConfirmation, setDeleteConfirmation] = useState(""); const [saving, setSaving] = useState(false); - const [initializing, setInitializing] = useState(false); + const [bindingOpen, setBindingOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [recoveryError, setRecoveryError] = useState(""); const [search, setSearch] = useState(""); @@ -134,6 +134,7 @@ export const SecretsManager: React.FC<{ requestEpoch.current++; setRevealed({}); setEditor(null); + setBindingOpen(false); setDeleteKey(""); setDeleteConfirmation(""); setRecoveryError(""); @@ -219,6 +220,10 @@ export const SecretsManager: React.FC<{ async function saveEditor() { if (!editor || !editor.key.trim() || saving || readOnly) return; + if (needsInitialization) { + setEditorError(t("secrets.notConfiguredHint")); + return; + } if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(editor.key.trim())) { setEditorError(t("secrets.invalidKey")); return; @@ -227,26 +232,6 @@ export const SecretsManager: React.FC<{ setEditorError(""); try { if (editor.mode === "create") { - if (needsInitialization) { - setInitializing(true); - const initialized = await initializeWorkspaceEnvironmentBackend( - workspaceEntryId, - environment, - project || undefined, - ); - setInitializing(false); - if (initialized.binding?.created) { - const binding = initialized.binding; - toast.success( - t(binding.requested_name ? "secrets.storageRenamed" : "secrets.storageCreated", { - name: binding.project_name, - requested: binding.requested_name, - }), - ); - } - // A missing remote folder can make listing fail before the first write. - await result.mutate().catch(() => undefined); - } await createSecret( workspaceEntryId, environment, @@ -274,7 +259,6 @@ export const SecretsManager: React.FC<{ } catch (error) { setEditorError((error as HttpError).message || t("secrets.saveFailed")); } finally { - setInitializing(false); setSaving(false); } } @@ -348,7 +332,7 @@ export const SecretsManager: React.FC<{ actionTrigger.current = null; setEditor({ mode: "create", key: "", value: "" }); }} - disabled={readOnly || (showError && !needsInitialization) || showLoading} + disabled={readOnly || showError || showLoading} > {t("secrets.add")} @@ -375,6 +359,12 @@ export const SecretsManager: React.FC<{ {recoveryError && listError.code === "INFISICAL_NOT_CONFIGURED" ? ( {recoveryError} ) : null} + {needsInitialization ? ( + + ) : null} )} diff --git a/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx b/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx index 31457b96..6826ac50 100644 --- a/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx +++ b/apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx @@ -1,27 +1,15 @@ -import { RefreshCw } from "lucide-react"; -import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { ErrorNotice } from "@/components/ui/page-layout"; -import { Skeleton } from "@/components/ui/skeleton"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; +import { KeyRound, RefreshCw } from "lucide-react"; import { getWorkspaceEnvironment, workspaceEnvironmentKey } from "@/api/workspace"; import { getProjects, getSession, message, sessionKey } from "@/api/session"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - manifestDraftKey, - useManifestDraftStore, -} from "@/features/manifest-draft/manifest-draft-store"; -import { SessionStatus } from "@/features/infisical-session/AccountSettings"; -import type { WorkspaceEnvironmentPatch } from "@/types/api"; +import { ErrorNotice } from "@/components/ui/page-layout"; +import { Skeleton } from "@/components/ui/skeleton"; +import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; + export function WorkspaceEnvironmentSettings({ environment, workspaceEntryId, @@ -33,6 +21,7 @@ export function WorkspaceEnvironmentSettings({ readOnly?: boolean; }) { const { t } = useTranslation(); + const [bindingOpen, setBindingOpen] = useState(false); const settings = useSWR(workspaceEnvironmentKey(workspaceEntryId, environment), () => getWorkspaceEnvironment(workspaceEntryId, environment), ); @@ -41,37 +30,9 @@ export function WorkspaceEnvironmentSettings({ session.data?.session.loggedIn ? "/infisical/projects" : null, getProjects, ); - const staged = useManifestDraftStore( - (s) => s.drafts[manifestDraftKey(workspaceEntryId)]?.workspace?.environment, - ); - const stage = useManifestDraftStore((s) => s.stageWorkspaceSection); - const initial: WorkspaceEnvironmentPatch = { - backend: "infisical", - ...(settings.data?.projectId - ? { - projectId: settings.data.projectId, - siteUrl: settings.data.siteUrl, - environments: settings.data.environments, - } - : {}), - }; - const value = staged ?? initial; - function change(next: WorkspaceEnvironmentPatch) { - if (!settings.data || readOnly) return; - stage({ - entryId: workspaceEntryId, - revision: settings.data.revision, - section: "environment", - initial, - next, - labels: { - backend: "overview.workspaceEnv.backend", - projectId: "global.project", - siteUrl: "session.site", - environments: "environmentSwitcher.label", - }, - }); - } + const bound = !!settings.data?.projectId; + const name = + projects.data?.find((p) => p.id === settings.data?.projectId)?.name || settings.data?.projectId; return ( - {staged ? ( -

- {t("overview.workspaceEnv.backendPending")} -

- ) : null}

{t("overview.workspaceEnv.title")}

- {settings.isLoading && } -
-
- -

Infisical

-
- {value.backend === "infisical" ? ( -
- - - - {!session.isLoading && !session.data?.session.loggedIn ? ( -

- {t("overview.workspaceEnv.signInHint")}{" "} - - {t("topbar.settings")} - -

- ) : projects.data?.length === 0 ? ( -

- {t("overview.workspaceEnv.noProjects")} -

- ) : null} -
- ) : null} -
-

{t("overview.workspaceEnv.backendSource")}

- {settings.error || projects.error ? ( + {settings.isLoading ? ( + + ) : settings.error ? ( { - void settings.mutate(); - void projects.mutate(); - }} - > + } > - {message(settings.error || projects.error)} + {message(settings.error)} - ) : null} + ) : ( +
+
+

Infisical

+ +
+ {bound ? ( +
+

{t("binding.current", { name })}

+

{settings.data?.siteUrl}

+

{settings.data?.environments.join(" / ")}

+
+ ) : ( +

{t("binding.workspaceHint")}

+ )} +
+ )} +

{t("binding.workspaceStorage")}

+
); diff --git a/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx b/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx index ac3c57ea..3666cd67 100644 --- a/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx +++ b/apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx @@ -4,7 +4,6 @@ import type React from "react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -89,28 +88,12 @@ export const WorkspaceSettingsDialog: React.FC<{ /> - {currentBackend === "infisical" ? ( - - ) : ( - - - - - -
-

{t("secrets.unavailableTitle")}

-

- {t("secrets.unavailableDescription")} -

-
-
-
- )} +
diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index ab01d5ea..b67a9b0d 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -253,7 +253,8 @@ "deleteDescription": "This removes the value from the selected Infisical environment and folder. This action cannot be undone here.", "deleteConfirmation": "Type {{key}} to confirm", "notConfiguredTitle": "Environment variable storage is not set up", - "notConfiguredHint": "One will create and connect an Infisical project when you save the first variable.", + "notConfiguredHint": "Create storage for this workspace or select an existing project. Add variables after confirming the binding.", + "connectStorage": "Connect Infisical project", "search": "Search secret keys", "noMatches": "No matching secrets", "scopeHint": "Changes here are saved directly to Infisical.", @@ -596,5 +597,31 @@ "exited": "Exited", "failed": "Failed" } + }, + "binding": { + "workspaceTitle": "Bind Infisical", + "globalTitle": "Bind shared credentials", + "change": "Change binding", + "unbound": "Infisical is not bound", + "workspaceHint": "Create storage for this workspace or select an existing project. Add variables after confirming the binding.", + "globalHint": "Create or reuse shared-credentials with default environment dev, or select an existing project.", + "method": "Binding method", + "automatic": "Create automatically", + "existing": "Select an existing project", + "current": "Current binding: {{name}}", + "target": "Target project: {{name}}", + "defaultEnvironment": "Default environment: dev", + "workspaceStorage": "Binding metadata is saved to one.manifest.toml. Variable values are stored in Infisical.", + "globalStorage": "Binding metadata is saved in local configuration. Credential values are stored in Infisical.", + "organizationRequired": "Select an organization and sign in again", + "devRequired": "Workspace projects must include a dev environment.", + "noProjects": "No projects are available. Switch to automatic creation.", + "pendingDraft": "This workspace has unsaved configuration. Save or discard it before binding a project.", + "conflict": "Workspace configuration has changed. Refresh and review the binding target before retrying.", + "success": "Bound to project {{name}}", + "binding": "Binding…", + "bindExisting": "Bind existing project", + "createAndBind": "Create and bind", + "prepareAndBind": "Create or reuse and bind" } } diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index e31678bd..f86e45b7 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -253,7 +253,8 @@ "deleteDescription": "这会从当前 Infisical 环境和 folder 中移除该值,无法在这里撤销。", "deleteConfirmation": "输入 {{key}} 确认删除", "notConfiguredTitle": "尚未配置环境变量存储", - "notConfiguredHint": "添加第一个变量并保存时,One 会自动创建并连接 Infisical 项目。", + "notConfiguredHint": "自动创建工作区存储项目,或选择已有项目。确认绑定后即可添加变量。", + "connectStorage": "连接 Infisical 项目", "search": "搜索密钥名称", "noMatches": "没有匹配的密钥", "scopeHint": "此处的修改会直接保存到 Infisical。", @@ -596,5 +597,31 @@ "exited": "已退出", "failed": "运行失败" } + }, + "binding": { + "workspaceTitle": "绑定 Infisical", + "globalTitle": "绑定共享凭据", + "change": "更换绑定", + "unbound": "尚未绑定 Infisical", + "workspaceHint": "自动创建工作区存储项目,或选择已有项目。确认绑定后即可添加变量。", + "globalHint": "自动创建或复用 shared-credentials,默认环境为 dev;也可以选择已有项目。", + "method": "绑定方式", + "automatic": "自动创建", + "existing": "选择已有项目", + "current": "当前绑定:{{name}}", + "target": "目标项目:{{name}}", + "defaultEnvironment": "默认环境:dev", + "workspaceStorage": "绑定信息写入 one.manifest.toml,变量值保存在 Infisical。", + "globalStorage": "绑定信息保存在本机配置,凭据值保存在 Infisical。", + "organizationRequired": "请选择组织并重新登录", + "devRequired": "工作区项目必须包含 dev 环境。", + "noProjects": "暂无可用项目,可切换到自动创建。", + "pendingDraft": "此工作区有未保存的配置,请先保存或放弃,再绑定项目。", + "conflict": "工作区配置已发生变化。请刷新并核对绑定目标后重试。", + "success": "已绑定到项目 {{name}}", + "binding": "正在绑定…", + "bindExisting": "绑定已有项目", + "createAndBind": "创建并绑定", + "prepareAndBind": "创建或复用并绑定" } } diff --git a/apps/dashboard/src/router/routes.test.tsx b/apps/dashboard/src/router/routes.test.tsx index fdea0171..e52d521e 100644 --- a/apps/dashboard/src/router/routes.test.tsx +++ b/apps/dashboard/src/router/routes.test.tsx @@ -329,7 +329,7 @@ describe("multi-workspace routing", () => { await user.click(within(inspector).getByRole("button", { name: "Workspace settings" })); const dialog = await screen.findByRole("dialog", { name: "Workspace settings" }); expect( - (within(dialog).getByRole("combobox", { name: "Storage project" }) as HTMLButtonElement) + ((await within(dialog).findByRole("button", { name: "Bind Infisical" })) as HTMLButtonElement) .disabled, ).toBe(true); }); diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md index 9cb090c2..b1dc0c67 100644 --- a/apps/docs/content/docs/en/create.md +++ b/apps/docs/content/docs/en/create.md @@ -52,7 +52,7 @@ a workflow that calls `one run ci` when needed. ## Infisical binding -Creation does not contact Infisical or write an `env` binding. Sign in with `one login` when you need managed variables. Only the first variable save (`one env set` or Dashboard Save) initializes the binding. Listing, refreshing, reading, and deleting never create a remote project. A name conflict triggers a short suffix, and One displays the actual remote project name. The local workspace name stays unchanged. Later writes use the stored remote project ID. Execution without a binding uses the shell environment. +Creation does not contact Infisical or write an `env` binding. When managed variables are needed, sign in with `one login`, then explicitly select or create storage with `one env bind`. Use `one env bind --create` to create and bind a workspace project, or `--project-id` to bind an existing project. Saving variables without a binding fails and never creates a project. A name conflict triggers a short suffix, and One displays the actual remote project name. The local workspace name stays unchanged. Later writes use the stored remote project ID. Execution without a binding uses the shell environment. ## Output diff --git a/apps/docs/content/docs/en/env-vars.md b/apps/docs/content/docs/en/env-vars.md index 6ef1910c..29c740f7 100644 --- a/apps/docs/content/docs/en/env-vars.md +++ b/apps/docs/content/docs/en/env-vars.md @@ -7,7 +7,16 @@ Infisical is One CLI's only managed environment source. Variables are fetched fo ## Setup -New workspaces have no Infisical binding and can run without signing in. Run `one login`, then save the first variable with `one env set` to initialize storage. Dashboard also initializes only when the first variable is saved; opening, refreshing, and cancelling never create a project. +New workspaces have no Infisical binding and can run without signing in. When managed variables are needed, run `one login`, then explicitly bind storage with `one env bind`. In an interactive terminal, select an existing project or create a workspace project. Scripts use `--create` to create and bind storage, or `--project-id` to bind an existing project. Existing bindings are preserved, and repeated creation commands do not create another project. + +```bash +one login +one env bind +one env bind --create +one env bind --project-id PROJECT_ID +``` + +`set`, `list`, reads, and `unset` all require a binding. An unbound workspace returns `INFISICAL_NOT_CONFIGURED` with guidance to run `one env bind`, without creating a project. In Dashboard, use **Bind Infisical** in workspace settings or the variables panel to create workspace storage or select an existing project. Review the target and confirm the binding before adding variables. Saving variables never initializes storage. The binding lives in the `[env.infisical]` table of `one.manifest.toml`: @@ -40,7 +49,7 @@ Use `--stdin` for multiline PEM files and other secrets without passing their co `env.infisical.environments` declares remote environment slugs. The default is always `dev`; `--env` selects another declared slug. A new binding defaults to `dev`, `staging`, and `prod`. These identifiers match Infisical's environment slugs, not its display names. Declaring a name does not create an environment remotely. -After a successful remote write, `set` can register a new environment locally with confirmation. The environment must already exist in Infisical. `list` rejects undeclared names with `ENV_UNKNOWN_ENVIRONMENT`. Listing, reading, and `unset` require an existing binding; unbound workspaces show guidance to save the first variable. +After a successful remote write, `set` can register a new environment locally with confirmation. The environment must already exist in Infisical. `list` rejects undeclared names with `ENV_UNKNOWN_ENVIRONMENT`. The shared folder is `/`. Project folders derive from their `path`: `services/api` maps to `/services/api`. Variables merge from `/` through `/services` to `/services/api`, with closer folders winning. Parallel tasks receive independent project environments. There are no project-level path, inheritance, key-list, or disable settings. @@ -55,13 +64,13 @@ With an `[env.infisical]` binding, `one run` and `one exec` fetch variables for ## Shared credentials -Shared credentials are independent of workspaces. Select storage with `one env bind --global`, browse names with `one env list --global --env dev --path /`, and inject an explicit scope with `one exec --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/). +Shared credentials are independent of workspaces. Run `one env bind --global` to create or reuse the default storage project while preserving any saved binding. Use `--project-id` to bind another existing project. Browse names with `one env list --global --env dev --path /`, and inject an explicit scope with `one exec --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/). ## Common errors | Code | Recovery | |---|---| -| `INFISICAL_NOT_CONFIGURED` | Sign in and save the first variable to create and connect a storage project | +| `INFISICAL_NOT_CONFIGURED` | Sign in and explicitly bind storage with `one env bind` | | `INFISICAL_AUTH_MISSING` / `INFISICAL_AUTH_FAILED` | Run `one login` and check access to the bound project | | `INFISICAL_PROJECT_NAME_TAKEN` | Select an existing project in Dashboard, or use a different workspace name | | `INFISICAL_PROJECT_CREATE_FORBIDDEN` | Select an existing accessible project in Dashboard | @@ -74,4 +83,4 @@ See [Manifest v2](/en/docs/manifest/) for the configuration structure and [the w ## Workspaces with the same name -Remote projects default to the workspace name. On a name conflict, One adds a short suffix and displays the actual name. Identically named workspaces do not share variables merely because of their names: writes target the stored `env.infisical.projectId`. Copying or cloning a configuration that already contains a binding reuses that remote project. If saving a variable fails after creation, the binding is retained and retries reuse it. +`one env bind --create` defaults to the workspace name. On a name conflict, One adds a short suffix and displays the actual name. Identically named workspaces do not share variables merely because of their names: writes target the stored `env.infisical.projectId`. Copying or cloning a configuration that already contains a binding reuses that remote project. Failed variable writes preserve the existing binding. diff --git a/apps/docs/content/docs/en/error-codes.md b/apps/docs/content/docs/en/error-codes.md index 995ed83d..4bcf0f5c 100644 --- a/apps/docs/content/docs/en/error-codes.md +++ b/apps/docs/content/docs/en/error-codes.md @@ -287,7 +287,7 @@ Could not reach Infisical. Check network and site URL. ### `INFISICAL_NOT_CONFIGURED` -This workspace has no Infisical project binding. Save the first variable with `one env set ` or Dashboard Save to create one. Reading, refreshing, and deleting do not initialize storage. +This workspace has no Infisical project binding. Explicitly bind storage with `one env bind`, or select a project in Dashboard and review and save the binding. Saving variables, reading, refreshing, and deleting never initialize storage. ### `INFISICAL_PROJECT_CREATE_FORBIDDEN` diff --git a/apps/docs/content/docs/en/login.md b/apps/docs/content/docs/en/login.md index 3f9c985f..d1296dfe 100644 --- a/apps/docs/content/docs/en/login.md +++ b/apps/docs/content/docs/en/login.md @@ -16,9 +16,9 @@ One keeps one active Infisical account. Complete login in the browser; the sessi ## 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. +In the Dashboard, open Shared credentials and select **Bind shared credentials** and confirm the target 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: +The CLI uses `--global` for shared credentials. After signing in, run `one env bind --global`. Without a saved binding, it creates or reuses the `shared-credentials` project in the current organization and binds environment `dev`, without asking you to select a project or enter an ID. Existing bindings are preserved, and repeated runs do not create another project. Use `--env` to select or change the default environment; it must already exist in the remote project. To use another existing project, provide `--project-id`: ```bash one env bind --global @@ -35,7 +35,7 @@ The CLI never displays secret values. Inject shared credentials with `one exec - ## 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. +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 bindings are reviewed and confirmed in the **Bind Infisical** dialog. Save or discard pending configuration first; stale configuration requires a refresh. Other Manifest changes keep the draft review and save workflow. ## Security boundaries diff --git a/apps/docs/content/docs/en/manifest.md b/apps/docs/content/docs/en/manifest.md index dd7c758a..ed5772b4 100644 --- a/apps/docs/content/docs/en/manifest.md +++ b/apps/docs/content/docs/en/manifest.md @@ -63,6 +63,6 @@ The shared remote folder is `/`. A project with `path = "services/api"` receives - Dashboard discovers service URLs from process output. - Personal One CLI preferences select stream or TUI output. -`one env set` stores values in Infisical. It persists a binding on first use and can register an environment locally after a successful write, but never writes variable names to the manifest. Dashboard previews the actual TOML before publishing binding changes and checks the file revision to reject stale drafts. Project settings display the conventions without per-project environment controls. +`one env bind` explicitly persists the Infisical binding. `one env set` requires a binding and stores values in Infisical. It can register an environment locally after a successful write, but never writes variable names to the manifest. Dashboard previews the actual TOML before publishing binding changes and checks the file revision to reject stale drafts. Project settings display the conventions without per-project environment controls. See [environment variables](/en/docs/env-vars/) for commands and [adding projects](/en/docs/add/) for project registration. diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index 7211263e..f7d82230 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -56,7 +56,7 @@ one create my-app --yes ## Infisical 绑定 -创建时不访问 Infisical,也不写入 `env` 绑定。需要托管变量时先通过 `one login` 登录,首次保存变量(`one env set` 或 Dashboard 保存)时才初始化绑定。查看、刷新、读取和删除不会创建远程项目。重名时会自动追加短后缀,并显示实际创建的远程项目名称;本地工作区名称保持原样。后续写入按保存的远程项目 ID 定位。未绑定时执行命令使用 shell 环境。 +创建时不访问 Infisical,也不写入 `env` 绑定。需要托管变量时先通过 `one login` 登录,再运行 `one env bind` 显式选择或创建远程项目。`one env bind --create` 创建并绑定工作区项目,`--project-id` 绑定已有项目。未绑定时保存变量会报错,不会自动创建项目。重名时会自动追加短后缀,并显示实际创建的远程项目名称;本地工作区名称保持原样。后续写入按保存的远程项目 ID 定位。未绑定时执行命令使用 shell 环境。 ## 输出 diff --git a/apps/docs/content/docs/zh/env-vars.md b/apps/docs/content/docs/zh/env-vars.md index 03089c25..ad101dda 100644 --- a/apps/docs/content/docs/zh/env-vars.md +++ b/apps/docs/content/docs/zh/env-vars.md @@ -7,7 +7,16 @@ Infisical 是 One CLI 唯一管理的环境变量来源。变量在执行命令 ## 绑定工作区 -新工作区没有 Infisical 绑定,无需登录即可运行。先执行 `one login`,首次使用 `one env set` 保存变量时会初始化绑定。Dashboard 也仅在首次点击保存时初始化;打开页面、刷新和取消编辑不会创建项目。 +新工作区没有 Infisical 绑定,无需登录即可运行。需要托管变量时先执行 `one login`,再通过 `one env bind` 显式绑定。交互终端中可以选择已有项目或创建工作区项目;脚本使用 `--create` 创建并绑定,或通过 `--project-id` 绑定已有项目。已有绑定会保留,重复创建命令不会新建另一个项目。 + +```bash +one login +one env bind +one env bind --create +one env bind --project-id PROJECT_ID +``` + +`set`、`list`、读取与 `unset` 都要求已有绑定。未绑定时返回 `INFISICAL_NOT_CONFIGURED` 并提示运行 `one env bind`,不会自动创建项目。Dashboard 在工作区设置和变量页面提供「绑定 Infisical」按钮,可自动创建工作区项目或选择已有项目。面板展示绑定目标,确认后保存绑定并允许添加变量;保存变量不会初始化绑定。 绑定保存在 `one.manifest.toml` 中的 `[env.infisical]` 表: @@ -40,7 +49,7 @@ one env list -p web --env dev `env.infisical.environments` 声明远程环境 slug。默认固定使用 `dev`,通过 `--env` 选择其他已声明环境。新绑定默认声明 `dev`、`staging`、`prod`,对应 Infisical 的环境标识,而不是界面展示名称。声明名称不会自动创建远程环境。 -`set` 经确认且远程写入成功后,可以在本地登记新环境名;对应环境必须已存在于 Infisical。`list` 对未声明名称返回 `ENV_UNKNOWN_ENVIRONMENT`。`list`、读取与 `unset` 只使用已有绑定,未绑定时提示先保存第一个变量。 +`set` 经确认且远程写入成功后,可以在本地登记新环境名;对应环境必须已存在于 Infisical。`list` 对未声明名称返回 `ENV_UNKNOWN_ENVIRONMENT`。 共享目录固定为 `/`。项目目录从 `path` 推导:`services/api` 对应 `/services/api`。变量依次合并 `/`、`/services` 和 `/services/api`,更具体的目录优先。并行任务各自接收所属项目的变量,不提供项目级目录、继承、变量名清单或停用设置。 @@ -55,13 +64,13 @@ one run dev --env dev ## 全局共享凭据 -共享凭据独立于工作区。通过 `one env bind --global` 选择存储项目,使用 `one env list --global --env dev --path /` 查看名称,使用 `one exec --global --env dev --path /folder -- command` 注入明确作用域。详见[登录与共享凭据](/zh/docs/login/)。 +共享凭据独立于工作区。通过 `one env bind --global` 自动创建或复用默认存储项目,已有绑定会保留;也可以通过 `--project-id` 绑定其他已有项目。使用 `one env list --global --env dev --path /` 查看名称,使用 `one exec --global --env dev --path /folder -- command` 注入明确作用域。详见[登录与共享凭据](/zh/docs/login/)。 ## 常见错误 | 错误码 | 恢复方法 | |---|---| -| `INFISICAL_NOT_CONFIGURED` | 登录后保存第一个变量,One 会自动创建并绑定存储项目 | +| `INFISICAL_NOT_CONFIGURED` | 登录后运行 `one env bind` 显式绑定存储项目 | | `INFISICAL_AUTH_MISSING` / `INFISICAL_AUTH_FAILED` | 执行 `one login` 并检查绑定项目的访问权限 | | `INFISICAL_PROJECT_NAME_TAKEN` | 在 Dashboard 选择已有项目,或使用不同的工作区名称 | | `INFISICAL_PROJECT_CREATE_FORBIDDEN` | 在 Dashboard 选择已有且可访问的项目 | @@ -74,4 +83,4 @@ one run dev --env dev ## 同名工作区 -远程项目默认使用工作区名称;名称冲突时自动添加短后缀,并显示实际名称。不同工作区不会仅因同名而共用变量,写入目标由 `env.infisical.projectId` 确定。复制或克隆包含绑定的配置会继续使用同一远程项目。创建后变量保存失败时,绑定会保留,重试复用该项目。 +`one env bind --create` 默认使用工作区名称;名称冲突时自动添加短后缀,并显示实际名称。不同工作区不会仅因同名而共用变量,写入目标由 `env.infisical.projectId` 确定。复制或克隆包含绑定的配置会继续使用同一远程项目。变量保存失败不会改变已有绑定。 diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index c6025024..66dc19ee 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -360,7 +360,7 @@ The workspace has no Infisical project binding. **Remediation**: -- `set-first-variable` — Save the first variable to create and connect an Infisical project.
运行:`one env set ` +- `set-first-variable` — Explicitly bind an Infisical project with one env bind first.
运行:`one env bind` ### `INFISICAL_PROJECT_CREATE_FORBIDDEN` @@ -410,7 +410,7 @@ The requested environment backend is not configured. **Remediation**: -- `configure-domain` — Save the first environment variable to set up storage.
运行:`one env set ` +- `configure-domain` — Explicitly bind environment variable storage with one env bind first.
运行:`one env bind` ### `BACKEND_VERB_NOT_SUPPORTED` diff --git a/apps/docs/content/docs/zh/login.md b/apps/docs/content/docs/zh/login.md index 4c7b74b7..c8f0e65b 100644 --- a/apps/docs/content/docs/zh/login.md +++ b/apps/docs/content/docs/zh/login.md @@ -18,9 +18,9 @@ One 只保留一个 Infisical 账号。登录会打开浏览器,完成后把 ## 共享凭据 -在 Dashboard 的「共享凭据」页点击「初始化默认位置」,即可创建或复用 `shared-credentials` 项目,并将 `dev` 环境的根目录作为默认浏览位置。也可以直接新建其他项目,或选择已有 Secret Manager 项目。已有存放位置会保留。 +在 Dashboard 的「共享凭据」页点击「绑定共享凭据」,在面板中确认后即可创建或复用 `shared-credentials` 项目,并将 `dev` 环境的根目录作为默认浏览位置。也可以直接新建其他项目,或选择已有 Secret Manager 项目。已有存放位置会保留。 -CLI 仍使用 `--global` 访问共享凭据,也可以手动选择存放位置: +CLI 使用 `--global` 访问共享凭据。登录后运行 `one env bind --global`,未绑定时会自动创建或复用当前组织中的 `shared-credentials` 项目,并绑定 `dev` 环境,无需选择项目或填写 ID。已有绑定会保留,重复执行不会重新创建项目;`--env` 可以指定或修改默认环境,所选环境必须已存在于远程项目中。需要使用其他已有项目时,显式传入 `--project-id`: ```bash one env bind --global @@ -37,7 +37,7 @@ CLI 不展示密钥值。通过 `one exec --global --env dev --path /docker -- c ## Dashboard -运行 `one serve`。设置页管理登录、等待回调、取消登录、退出和语言;共享凭据页管理存放项目、浏览环境与目录,以及增删改查变量。查看或复制时才读取明文,切换账号、环境、目录或离开页面会清除页面中的明文。远端变量操作即时生效;工作区绑定项目等 Manifest 修改先进入草稿,审阅后一次保存。 +运行 `one serve`。设置页管理登录、等待回调、取消登录、退出和语言;共享凭据页管理存放项目、浏览环境与目录,以及增删改查变量。查看或复制时才读取明文,切换账号、环境、目录或离开页面会清除页面中的明文。远端变量操作即时生效。工作区通过「绑定 Infisical」面板审阅目标并确认保存;未保存的配置需先保存或放弃,过期配置会提示刷新。其他 Manifest 修改仍通过草稿审阅后保存。 ## 安全边界 diff --git a/apps/docs/content/docs/zh/manifest.md b/apps/docs/content/docs/zh/manifest.md index fd4fa35d..9eb02116 100644 --- a/apps/docs/content/docs/zh/manifest.md +++ b/apps/docs/content/docs/zh/manifest.md @@ -63,6 +63,6 @@ toolchain = "go" - Dashboard 从进程输出发现服务访问地址。 - stream 或 TUI 输出模式由 One CLI 个人偏好控制。 -`one env set` 将变量值保存到 Infisical。首次使用会保存绑定,成功写入后可登记新的环境名,但不会把变量名写入 Manifest。Dashboard 发布绑定修改前展示实际 TOML,并通过文件 revision 拒绝过期草稿。项目设置展示约定,不再提供项目级环境覆盖开关。 +`one env bind` 显式保存 Infisical 绑定。`one env set` 要求已有绑定,将变量值保存到 Infisical;成功写入后可登记新的环境名,但不会把变量名写入 Manifest。Dashboard 发布绑定修改前展示实际 TOML,并通过文件 revision 拒绝过期草稿。项目设置展示约定,不再提供项目级环境覆盖开关。 具体命令参阅[环境变量](/zh/docs/env-vars/),项目登记参阅[添加项目](/zh/docs/add/)。 diff --git a/apps/docs/content/tutorials/en/env-vars.mdx b/apps/docs/content/tutorials/en/env-vars.mdx index 14377b0d..9c557451 100644 --- a/apps/docs/content/tutorials/en/env-vars.mdx +++ b/apps/docs/content/tutorials/en/env-vars.mdx @@ -14,7 +14,15 @@ one whoami The browser session is stored in the system keyring. New workspaces can be created and run without a binding; sign in when managed variables are needed. -## 2. Set a variable +## 2. Explicitly bind Infisical + +```bash +one env bind +``` + +Select an existing project or create a new one interactively. Scripts can use `one env bind --create` to create and bind a workspace project, or `one env bind --project-id PROJECT_ID` to bind an existing project. In Dashboard, select a project, then review and save the binding. + +## 3. Set a variable From a workspace containing a project named `api`: @@ -22,11 +30,11 @@ From a workspace containing a project named `api`: one env set DATABASE_URL -p api --env dev ``` -The terminal asks for the value with hidden input. The first variable save initializes the Infisical project binding if absent. To use an existing project, select it in Dashboard workspace settings before running the command. +The terminal asks for the value with hidden input. Without a binding, the command fails with guidance to run `one env bind`. Saving variables never creates a remote project. At the workspace root, omit `-p` for shared values; interactive `set` offers a scope selector. For scripts, pass a value explicitly and use `--yes` to confirm changes. -## 3. Inspect names +## 4. Inspect names ```bash one env list -p api --env dev @@ -34,7 +42,7 @@ one env list -p api --env dev `list` shows names only. The CLI does not print secret values; use `one exec` to inject them into commands. -## 4. Run the project +## 5. Run the project ```bash one exec -p api --env dev -- go run ./cmd/server diff --git a/apps/docs/content/tutorials/en/infisical-login.mdx b/apps/docs/content/tutorials/en/infisical-login.mdx index 62529e71..8b415205 100644 --- a/apps/docs/content/tutorials/en/infisical-login.mdx +++ b/apps/docs/content/tutorials/en/infisical-login.mdx @@ -16,7 +16,7 @@ One keeps one active Infisical account. Complete login in the browser; the sessi ## Global variables -Choose an existing Infisical project and environment: +Run `one env bind --global` to create or reuse `shared-credentials` with environment `dev`. Existing bindings are preserved, and no project ID is required. You can also explicitly select another existing project: ```bash one env bind --global 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 3eb43463..a52c5613 100644 --- a/apps/docs/content/tutorials/en/json-output-error-codes.mdx +++ b/apps/docs/content/tutorials/en/json-output-error-codes.mdx @@ -131,7 +131,7 @@ Handle errors relevant to your operation and preserve the original cause for oth | 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` | | Tools and tasks | `MISE_INSTALL_FAILED`, `MISE_CONFIG_CONFLICT`, `RUNTIME_TASK_NOT_FOUND` | Inspect download or configuration errors; list tasks with `one run` | -| Infisical | `INFISICAL_NOT_CONFIGURED`, `INFISICAL_AUTH_MISSING`, `INFISICAL_AUTH_FAILED` | Sign in and save the first variable to bind storage; sign in again for an expired session | +| Infisical | `INFISICAL_NOT_CONFIGURED`, `INFISICAL_AUTH_MISSING`, `INFISICAL_AUTH_FAILED` | Sign in and explicitly bind storage with `one env bind`; sign in again for an expired session | | Variables | `ENV_UNKNOWN_ENVIRONMENT`, `ENV_SET_OVERWRITE_REQUIRED` | Select a declared environment, or confirm an overwrite with `--yes` | | Serve | `SERVE_PORT_BUSY`, `SERVE_BIND_FORBIDDEN` | Change the host or port flag, then restart | diff --git a/apps/docs/content/tutorials/zh/env-vars.mdx b/apps/docs/content/tutorials/zh/env-vars.mdx index 21f2a517..bb25b009 100644 --- a/apps/docs/content/tutorials/zh/env-vars.mdx +++ b/apps/docs/content/tutorials/zh/env-vars.mdx @@ -14,7 +14,15 @@ one whoami 浏览器会话保存在系统 keyring。新工作区可以先不绑定,正常创建和运行;需要托管变量时再登录。 -## 2. 设置变量 +## 2. 显式绑定 Infisical + +```bash +one env bind +``` + +交互选择已有项目或创建新项目。脚本可以使用 `one env bind --create` 创建并绑定工作区项目,或通过 `one env bind --project-id PROJECT_ID` 绑定已有项目。Dashboard 中也可以选择已有项目,审阅后保存绑定。 + +## 3. 设置变量 在包含 `api` 项目的工作区中执行: @@ -22,11 +30,11 @@ one whoami one env set DATABASE_URL -p api --env dev ``` -终端会隐藏输入值。首次保存变量在缺少绑定时初始化 Infisical 项目;如需使用已有项目,先在 Dashboard 工作区设置中选择该项目。 +终端会隐藏输入值。未绑定时直接报错并提示运行 `one env bind`;保存变量不会创建远程项目。 工作区根目录省略 `-p` 可操作共享变量,交互式 `set` 提供作用域选择。脚本显式传入值,并使用 `--yes` 确认变更。 -## 3. 查看变量名 +## 4. 查看变量名 ```bash one env list -p api --env dev @@ -34,7 +42,7 @@ one env list -p api --env dev `list` 只显示名称。CLI 不输出密钥值;通过 `one exec` 将其注入命令。 -## 4. 运行项目 +## 5. 运行项目 ```bash one exec -p api --env dev -- go run ./cmd/server diff --git a/apps/docs/content/tutorials/zh/infisical-login.mdx b/apps/docs/content/tutorials/zh/infisical-login.mdx index c00ec6c6..93931239 100644 --- a/apps/docs/content/tutorials/zh/infisical-login.mdx +++ b/apps/docs/content/tutorials/zh/infisical-login.mdx @@ -18,7 +18,7 @@ One 只保留一个 Infisical 账号。登录会打开浏览器,完成后把 ## 全局变量 -在 Infisical 中准备一个已有项目和环境,然后选择存放位置: +运行 `one env bind --global` 自动创建或复用 `shared-credentials` 项目,并绑定 `dev` 环境。已有绑定会保留,无需手动填写项目 ID;也可以显式指定其他已有项目: ```bash one env bind --global diff --git a/packages/cli/internal/adapters/env/infisical/init.go b/packages/cli/internal/adapters/env/infisical/init.go index 7c96a577..87459a1b 100644 --- a/packages/cli/internal/adapters/env/infisical/init.go +++ b/packages/cli/internal/adapters/env/infisical/init.go @@ -35,6 +35,9 @@ type InitInput struct { // Note: skipping verify also disables auto-create — the resolved // projectId must be supplied explicitly. SkipVerify bool + // BeforeWrite rejects stale Dashboard requests after remote work and before + // publishing any local changes. The caller holds the manifest lock. + BeforeWrite func() error } // InitResult is the JSON payload emitted by auto-bind. Mirrors the @@ -123,6 +126,11 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e cfg.ProjectName = resolvedName authStatus = "created" created = true + if in.BeforeWrite != nil { + if err := in.BeforeWrite(); err != nil { + return nil, bindingWriteError(projectRoot, cfg, err) + } + } // Back-fill the manifest's workspace identity. New scaffolds set // workspace at create time; older workspaces (or those that lost the @@ -147,6 +155,11 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e } authStatus = "verified" } + if !created && in.BeforeWrite != nil { + if err := in.BeforeWrite(); err != nil { + return nil, err + } + } configJSON, err := EncodeManifestConfig(cfg) if err != nil { @@ -301,7 +314,12 @@ func dedupeStrings(in []string) []string { } func bindingWriteError(root string, cfg *WorkspaceConfig, err error) error { - return cliErrors.New(cliErrors.ONE_CLI_ERROR, + code := cliErrors.ONE_CLI_ERROR + var original *output.Error + if errors.As(err, &original) && original.Code == string(cliErrors.SERVE_MANIFEST_CONFLICT) { + code = cliErrors.SERVE_MANIFEST_CONFLICT + } + return cliErrors.New(code, i18n.Errorf("infisical.init.binding_write_failed", cfg.ProjectName, cfg.ProjectID, workspace.ManifestPath(root), err).Error()). WithContext(map[string]any{"project_id": cfg.ProjectID, "project_name": cfg.ProjectName, "partial_state": "project_created_binding_unsaved"}).WithCause(err) } diff --git a/packages/cli/internal/adapters/env/infisical/shared_location.go b/packages/cli/internal/adapters/env/infisical/shared_location.go index 4983e8ac..b2988f24 100644 --- a/packages/cli/internal/adapters/env/infisical/shared_location.go +++ b/packages/cli/internal/adapters/env/infisical/shared_location.go @@ -49,6 +49,14 @@ func createProjectFor(ctx context.Context, s *session.Session, name string) (*Re // 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 BindDefaultGlobal(ctx, "") +} + +// BindDefaultGlobal preserves the saved project, or creates/reuses the default +// project when unbound. An empty environment preserves the saved environment, +// defaulting to dev for a new binding. Explicit environments are validated before +// saving the location. +func BindDefaultGlobal(ctx context.Context, environment string) (*GlobalLocation, error) { return withLocationLock(ctx, func() (*GlobalLocation, error) { s, err := session.Require() if err != nil { @@ -62,7 +70,13 @@ func EnsureDefaultGlobal(ctx context.Context) (*GlobalLocation, error) { 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 environment == "" { + environment = location.DefaultEnvironment + } + return bindGlobalFor(ctx, s, location.ProjectID, environment) + } + if environment == "" { + environment = DefaultSharedEnvironment } if s.OrganizationID == "" { return nil, i18n.Errorf("infisical.organization_required") @@ -86,7 +100,7 @@ func EnsureDefaultGlobal(ctx context.Context) (*GlobalLocation, error) { return nil, err } } - return bindGlobalFor(ctx, s, selected.ID, DefaultSharedEnvironment) + return bindGlobalFor(ctx, s, selected.ID, environment) }) } diff --git a/packages/cli/internal/application/manifest/service.go b/packages/cli/internal/application/manifest/service.go index c25ba87b..63530bcf 100644 --- a/packages/cli/internal/application/manifest/service.go +++ b/packages/cli/internal/application/manifest/service.go @@ -12,6 +12,7 @@ import ( catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) @@ -99,6 +100,11 @@ func (s *Service) ApplyManifestDraft( ) (ApplyManifestResult, error) { s.mu.Lock() defer s.mu.Unlock() + unlock, err := fsutil.WorkspaceLock(ctx, root, "manifest") + if err != nil { + return ApplyManifestResult{}, err + } + defer unlock() hasWorkspaceChange := input.Workspace != nil && input.Workspace.Environment != nil if strings.TrimSpace(input.Revision) == "" || (!hasWorkspaceChange && len(input.Changes) == 0) { diff --git a/packages/cli/internal/modules/environment/binding_test.go b/packages/cli/internal/modules/environment/binding_test.go index 58667ca1..b8576959 100644 --- a/packages/cli/internal/modules/environment/binding_test.go +++ b/packages/cli/internal/modules/environment/binding_test.go @@ -43,6 +43,7 @@ func TestReadsAndDeletesNeverInitializeOrModifyAnUnboundWorkspace(t *testing.T) scope := unboundScope(t) before, _ := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) for _, operation := range []func() error{ + func() error { _, err := service.PlanSet(PlanSetInput{Scope: scope}); return err }, func() error { _, err := service.List(context.Background(), ListInput{Scope: scope}); return err }, func() error { _, err := service.Get(context.Background(), GetInput{Scope: scope, Key: "TOKEN"}) @@ -75,6 +76,68 @@ func mockSession(t *testing.T, site string) { t.Cleanup(func() { _ = keyring.Delete("one-cli.infisical", "session") }) } +func TestWorkspaceBindingRejectsStaleRevisionBeforeRemoteWork(t *testing.T) { + service := newTestService(t) + scope := unboundScope(t) + service.initInfisical = func(context.Context, string, infisical.InitInput) (*infisical.InitResult, error) { + t.Fatal("stale binding contacted Infisical") + return nil, nil + } + _, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true, Revision: "stale"}) + var coded *output.Error + if !errors.As(err, &coded) || coded.Code != "SERVE_MANIFEST_CONFLICT" { + t.Fatalf("expected revision conflict, got %v", err) + } +} + +func TestWorkspaceBindingPreservesExternalEditDuringCreation(t *testing.T) { + service := newTestService(t) + scope := unboundScope(t) + root := scope.WorkingDirectory() + _, revision, err := workspace.ReadManifestSnapshot(root) + if err != nil { + t.Fatal(err) + } + creates := 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/v2/workspace": + creates++ + manifest, err := workspace.ReadManifest(root) + if err != nil { + t.Error(err) + } + manifest.Workspace.Name = "edited-during-creation" + if err := workspace.WriteManifest(root, manifest); err != nil { + t.Error(err) + } + fmt.Fprint(w, `{"project":{"id":"created","name":"demo"}}`) + case "/api/v1/workspace/created": + fmt.Fprint(w, `{"workspace":{"id":"created","name":"demo","type":"secret-manager","environments":[{"slug":"dev"}]}}`) + case "/api/v3/secrets/raw": + fmt.Fprint(w, `{"secrets":[]}`) + default: + t.Errorf("unexpected request: %s", r.URL) + } + })) + defer upstream.Close() + mockSession(t, upstream.URL) + _, err = service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true, Revision: revision}) + var coded *output.Error + if !errors.As(err, &coded) || coded.Code != "SERVE_MANIFEST_CONFLICT" || coded.Context["project_id"] != "created" { + t.Fatalf("expected recoverable created project, got %v", err) + } + manifest, current, err := workspace.ReadManifestSnapshot(root) + if err != nil || manifest.Workspace.Name != "edited-during-creation" || manifest.Env != nil { + t.Fatalf("binding overwrote external edits: %+v %v", manifest, err) + } + result, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, ProjectID: "created", Revision: current}) + if err != nil || result.ProjectID != "created" || creates != 1 { + t.Fatalf("recovery created another project: %+v %v, creations=%d", result, err, creates) + } +} + func TestSameNamedWorkspacesCreateDistinctProjectsAndRetryUsesBinding(t *testing.T) { var mu sync.Mutex names := map[string]string{} @@ -134,24 +197,31 @@ func TestSameNamedWorkspacesCreateDistinctProjectsAndRetryUsesBinding(t *testing scopes := []execution.Scope{unboundScope(t), unboundScope(t)} ids := []string{} for index, scope := range scopes { + binding, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}) + if err != nil { + t.Fatal(err) + } plan, err := service.PlanSet(PlanSetInput{Scope: scope}) if err != nil { t.Fatal(err) } - result, err := service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: fmt.Sprint(index)}) + _, err = service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: fmt.Sprint(index)}) if err != nil { t.Fatal(err) } - if result.Binding == nil || !result.Binding.Created { - t.Fatalf("binding=%+v", result.Binding) + if !binding.Created { + t.Fatalf("binding=%+v", binding) } - ids = append(ids, result.Binding.ProjectID) - if index == 1 && (result.Binding.RequestedName != "demo" || !strings.HasPrefix(result.Binding.ProjectName, "demo-")) { - t.Fatalf("collision metadata=%+v", result.Binding) + ids = append(ids, binding.ProjectID) + if index == 1 && (binding.RequestedName != "demo" || !strings.HasPrefix(binding.ProjectName, "demo-")) { + t.Fatalf("collision metadata=%+v", binding) } - again, err := service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: fmt.Sprint(index)}) - if err != nil || again.Binding.Created { - t.Fatalf("repeat save=%+v,%v", again, err) + again, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}) + if err != nil || again.Created || again.ProjectID != binding.ProjectID { + t.Fatalf("repeat binding=%+v,%v", again, err) + } + if _, err := service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: fmt.Sprint(index)}); err != nil { + t.Fatal(err) } } if ids[0] == ids[1] || len(names) != 2 || writes[ids[0]] != 1 || writes[ids[1]] != 1 { @@ -184,20 +254,28 @@ func TestSameNamedWorkspacesCreateDistinctProjectsAndRetryUsesBinding(t *testing failSave = true mu.Unlock() scope := unboundScope(t) + bound, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}) + if err != nil { + t.Fatal(err) + } plan, err := service.PlanSet(PlanSetInput{Scope: scope}) if err != nil { t.Fatal(err) } _, err = service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: "third"}) var coded *output.Error - if !errors.As(err, &coded) || coded.Context["partial_state"] != "project_bound" { - t.Fatalf("partial failure=%v", err) + if !errors.As(err, &coded) { + t.Fatalf("variable write failure=%v", err) + } + manifest, readErr := workspace.ReadManifest(scope.WorkingDirectory()) + if readErr != nil || manifest.Env.ProjectID != bound.ProjectID { + t.Fatalf("failed variable write changed binding: %+v %v", manifest, readErr) } mu.Lock() failSave = false mu.Unlock() retried, err := service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: "third"}) - if err != nil || retried.Binding.Created || len(names) != 3 { + if err != nil || len(names) != 3 { t.Fatalf("retry=%+v,%v; projects=%v", retried, err, names) } } @@ -205,10 +283,6 @@ func TestSameNamedWorkspacesCreateDistinctProjectsAndRetryUsesBinding(t *testing func TestBindingInitializationIsSerializedAndMalformedConfigIsRejected(t *testing.T) { service := newTestService(t) scope := unboundScope(t) - active, err := execution.ResolveWorkspaceScope(scope) - if err != nil { - t.Fatal(err) - } calls := 0 service.initInfisical = func(_ context.Context, root string, _ infisical.InitInput) (*infisical.InitResult, error) { calls++ @@ -227,7 +301,7 @@ func TestBindingInitializationIsSerializedAndMalformedConfigIsRejected(t *testin wg.Add(1) go func() { defer wg.Done() - if _, err := service.ensureInfisicalBound(context.Background(), active); err != nil { + if _, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}); err != nil { t.Error(err) } }() @@ -239,7 +313,7 @@ func TestBindingInitializationIsSerializedAndMalformedConfigIsRejected(t *testin if err := os.WriteFile(workspace.ManifestPath(scope.WorkingDirectory()), []byte("broken"), 0644); err != nil { t.Fatal(err) } - if _, err := service.ensureInfisicalBound(context.Background(), active); err == nil { + if _, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}); err == nil { t.Fatal("malformed config accepted") } if calls != 1 { diff --git a/packages/cli/internal/modules/environment/global.go b/packages/cli/internal/modules/environment/global.go index 28af7d24..32faf269 100644 --- a/packages/cli/internal/modules/environment/global.go +++ b/packages/cli/internal/modules/environment/global.go @@ -42,3 +42,6 @@ func CreateRemoteProject(ctx context.Context, name string) (*RemoteProject, erro func EnsureDefaultGlobal(ctx context.Context) (*GlobalLocation, error) { return remote.EnsureDefaultGlobal(ctx) } +func BindDefaultGlobal(ctx context.Context, env string) (*GlobalLocation, error) { + return remote.BindDefaultGlobal(ctx, env) +} diff --git a/packages/cli/internal/modules/environment/operations.go b/packages/cli/internal/modules/environment/operations.go index fa7a1dd2..625fb821 100644 --- a/packages/cli/internal/modules/environment/operations.go +++ b/packages/cli/internal/modules/environment/operations.go @@ -2,7 +2,6 @@ package environment import ( "context" - "errors" "strings" "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" @@ -11,7 +10,6 @@ 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/ports/secrets" ) @@ -106,7 +104,6 @@ type PlanSetInput struct { type SetPlan struct { Environment string - NeedsBinding bool NeedsEnvironmentCreation bool ProjectChoices []string resolution resolution @@ -127,12 +124,10 @@ func (s *Service) PlanSet(input PlanSetInput) (SetPlan, error) { if err := validateWriteEnvironment(resolved); err != nil { return SetPlan{}, err } - config, err := infisical.LoadWorkspaceConfig(resolved.Workspace.Root()) - if err != nil { + if err := requireInfisicalBackend(resolved); err != nil { return SetPlan{}, err } plan := SetPlan{ - NeedsBinding: config == nil || strings.TrimSpace(config.ProjectID) == "", Environment: resolved.Scope.Environment(), NeedsEnvironmentCreation: resolved.Scope.Environment() != "" && !contains(resolved.Declared, resolved.Scope.Environment()), resolution: resolved, @@ -177,15 +172,15 @@ func (s *Service) Set(ctx context.Context, input SetInput) (*SetResult, error) { if err := validateWriteEnvironment(resolution); err != nil { return nil, err } - // Validate selectors before authentication, local writes, or remote creation. + if err := requireInfisicalBackend(resolution); err != nil { + return nil, err + } + // Validate selectors before authentication or remote writes. path, err := s.resolveInfisicalFolderPath(resolution.Workspace, nil, input.Plan.project) if err != nil { return nil, err } if input.RepositoryReadOnly { - if err := requireInfisicalBackend(resolution); err != nil { - return nil, err - } if environment != "" && !contains(resolution.Declared, environment) { return nil, cliErrors.New(cliErrors.ENV_UNKNOWN_ENVIRONMENT, i18n.T("env.dashboard_environment_required")) @@ -195,31 +190,23 @@ func (s *Service) Set(ctx context.Context, input SetInput) (*SetResult, error) { if err != nil { return nil, err } - var binding *BindingResult - if !input.RepositoryReadOnly { - binding, err = s.ensureInfisicalBound(ctx, resolution.Workspace) - if err != nil { - return nil, err - } - } - result, err := infisical.Set(ctx, root, infisical.SetInput{ Env: environment, Path: path, Key: input.Key, Value: input.Value, Overwrite: input.Overwrite, Cfg: config, Creds: credentials, }) if result == nil || err != nil { - return nil, bindingFailure(err, binding) + return nil, err } createdEnvironment := false if !input.RepositoryReadOnly && environment != "" && !contains(resolution.Declared, environment) { if _, err := workspace.EnsureEnvironment(root, environment); err != nil { - return nil, bindingFailure(err, binding) + return nil, err } createdEnvironment = true } return &SetResult{ - Schema: result.Schema, Environment: result.Env, Path: result.Path, Binding: binding, + Schema: result.Schema, Environment: result.Env, Path: result.Path, Key: result.Key, Action: result.Action, CreatedEnvironment: createdEnvironment, }, nil } @@ -290,22 +277,3 @@ func validateWriteEnvironment(resolution resolution) error { } return nil } - -// Preserve the specific failure and recovery while reporting a completed binding. -func bindingFailure(err error, binding *BindingResult) error { - if err == nil || binding == nil || !binding.Created { - return err - } - var original *output.Error - if !errors.As(err, &original) { - original = cliErrors.New(cliErrors.ONE_CLI_ERROR, err.Error()) - } - result := *original - result.Message = i18n.Tf("env.bound_before_failure", binding.ProjectName, binding.ProjectID, err) - ctx := make(map[string]any, len(original.Context)+3) - for key, value := range original.Context { - ctx[key] = value - } - ctx["project_id"], ctx["project_name"], ctx["partial_state"] = binding.ProjectID, binding.ProjectName, "project_bound" - return result.WithContext(ctx).WithCause(err) -} diff --git a/packages/cli/internal/modules/environment/result.go b/packages/cli/internal/modules/environment/result.go index 12c9308b..7f385ccd 100644 --- a/packages/cli/internal/modules/environment/result.go +++ b/packages/cli/internal/modules/environment/result.go @@ -35,6 +35,13 @@ type BindingResult struct { RequestedName string `json:"requested_name,omitempty"` } +type WorkspaceBindingResult struct { + BindingResult + Schema string `json:"schema"` + Environments []string `json:"environments"` + WrittenTo string `json:"written_to"` +} + type SetResult struct { Binding *BindingResult `json:"binding,omitempty"` Schema string `json:"schema"` diff --git a/packages/cli/internal/modules/environment/service.go b/packages/cli/internal/modules/environment/service.go index 7464c8f1..a4bdc758 100644 --- a/packages/cli/internal/modules/environment/service.go +++ b/packages/cli/internal/modules/environment/service.go @@ -106,7 +106,7 @@ func (s *Service) Summary(scope execution.Scope) (*Summary, error) { DefaultEnvironment: defaultEnvironment, AvailableEnvironments: environments, Scope: "workspace", - Commands: []string{"one env set ", "one env list", "one exec -- "}, + Commands: []string{"one env bind", "one env set ", "one env list", "one exec -- "}, } if project, ok := resolution.Workspace.ProjectFromWorkingDirectory(); ok { result.Scope = "project" diff --git a/packages/cli/internal/modules/environment/target.go b/packages/cli/internal/modules/environment/target.go index 70ef995c..64664aac 100644 --- a/packages/cli/internal/modules/environment/target.go +++ b/packages/cli/internal/modules/environment/target.go @@ -21,25 +21,83 @@ func (s *Service) resolveInfisical() (*infisical.WorkspaceConfig, *infisical.Cre return &infisical.WorkspaceConfig{SiteURL: current.SiteURL}, &infisical.Credentials{AccessToken: current.Token}, nil } -func (s *Service) ensureInfisicalBound( - ctx context.Context, - activeWorkspace execution.Workspace, -) (*BindingResult, error) { +type BindWorkspaceInput struct { + Scope execution.Scope + ProjectID string + Create bool + // Revision is required by Dashboard callers, and omitted by the CLI. + Revision string +} + +// BindWorkspace is the explicit boundary for creating or selecting Infisical +// storage. Variable writes only use an existing binding. +func (s *Service) BindWorkspace(ctx context.Context, input BindWorkspaceInput) (*WorkspaceBindingResult, error) { + if input.Create && strings.TrimSpace(input.ProjectID) != "" { + return nil, i18n.Errorf("env.bind.create_conflict") + } + activeWorkspace, err := execution.ResolveWorkspaceScope(input.Scope) + if err != nil { + return nil, err + } projectRoot := activeWorkspace.Root() unlock, err := fsutil.WorkspaceLock(ctx, projectRoot, "infisical-binding") if err != nil { return nil, err } defer unlock() - // Re-read after locking: another CLI or Dashboard save may have bound it. + unlockManifest, err := fsutil.WorkspaceLock(ctx, projectRoot, "manifest") + if err != nil { + return nil, err + } + defer unlockManifest() + checkRevision := func() error { + if input.Revision == "" { + return nil + } + _, revision, err := workspace.ReadManifestSnapshot(projectRoot) + if err != nil { + return err + } + if input.Revision != revision { + return cliErrors.New(cliErrors.SERVE_MANIFEST_CONFLICT, i18n.T("env.bind.revision_conflict")).WithContext(map[string]any{ + "expected_revision": input.Revision, "current_revision": revision, + }) + } + return nil + } + if err := checkRevision(); err != nil { + return nil, err + } + // Re-read after locking: another explicit binding may have completed. config, err := infisical.LoadWorkspaceConfig(projectRoot) if err != nil { return nil, err } - if config != nil && strings.TrimSpace(config.ProjectID) != "" { - return &BindingResult{ProjectID: config.ProjectID, ProjectName: config.ProjectName}, nil + projectID := strings.TrimSpace(input.ProjectID) + if projectID == "" && config != nil && strings.TrimSpace(config.ProjectID) != "" { + return &WorkspaceBindingResult{ + Schema: "one-cli/env-bind/v1", BindingResult: BindingResult{ProjectID: config.ProjectID, ProjectName: config.ProjectName}, + Environments: config.Environments, WrittenTo: workspace.ManifestPath(projectRoot), + }, nil + } + if projectID == "" && !input.Create { + return nil, i18n.Errorf("env.bind.selection_required") } - result, err := s.initInfisical(ctx, projectRoot, infisical.InitInput{}) + initInput := infisical.InitInput{ProjectID: projectID, BeforeWrite: checkRevision} + if projectID != "" { + project, err := infisical.Project(ctx, projectID) + if err != nil { + return nil, err + } + initInput.ProjectName = project.Name + for _, environment := range project.Environments { + initInput.Environments = append(initInput.Environments, environment.Slug) + } + if !contains(initInput.Environments, "dev") { + return nil, i18n.Errorf("manifest.dev_required") + } + } + result, err := s.initInfisical(ctx, projectRoot, initInput) if err != nil { return nil, err } @@ -48,10 +106,13 @@ func (s *Service) ensureInfisicalBound( if identity := activeWorkspace.Manifest().Workspace; identity != nil { requestedName = identity.Name } - if requestedName != result.ProjectName { + if result.Created && requestedName != result.ProjectName { binding.RequestedName = requestedName } - return binding, nil + return &WorkspaceBindingResult{ + Schema: "one-cli/env-bind/v1", BindingResult: *binding, + Environments: result.Environments, WrittenTo: result.WrittenTo, + }, nil } func requireInfisicalBackend(resolution resolution) error { @@ -72,8 +133,8 @@ func (s *Service) RequireInfisicalBackend( return requireInfisicalBackend(resolution) } -// EnsureInfisicalReady is the Dashboard initialization write boundary, called -// only when saving the first secret. Reads and retries never initialize storage. +// EnsureInfisicalReady is the explicit Dashboard initialization endpoint. +// Saving variables never calls it. func (s *Service) EnsureInfisicalReady( ctx context.Context, scope execution.Scope, @@ -99,7 +160,11 @@ func (s *Service) EnsureInfisicalReady( if _, err := s.resolveInfisicalFolderPath(resolution.Workspace, nil, project); err != nil { return nil, err } - return s.ensureInfisicalBound(ctx, resolution.Workspace) + result, err := s.BindWorkspace(ctx, BindWorkspaceInput{Scope: scope, Create: true}) + if err != nil { + return nil, err + } + return &result.BindingResult, nil } func (s *Service) resolveInfisicalFolderPath( diff --git a/packages/cli/internal/platform/errors/codes.go b/packages/cli/internal/platform/errors/codes.go index 949d5a73..07e1fb22 100644 --- a/packages/cli/internal/platform/errors/codes.go +++ b/packages/cli/internal/platform/errors/codes.go @@ -185,7 +185,7 @@ var Codes = map[Code]Definition{ SUBPROJECT_NOT_FOUND: {Summary: "-p / --project named a project that does not exist in manifest.projects.", Remediation: []output.Remediation{{Action: "list-projects", Hint: "Check the project name or switch to its directory."}}}, PATCH_CONFLICT: {Summary: "Two configuration fragments contributed conflicting patches to the same backend target."}, BACKEND_INVOKE_FAILED: {Summary: "Backend's Invoke method returned an error."}, - BACKEND_NOT_ENABLED: {Summary: "The requested environment backend is not configured.", Remediation: []output.Remediation{{Action: "configure-domain", Hint: "Save the first environment variable to set up storage.", Command: "one env set "}}}, + BACKEND_NOT_ENABLED: {Summary: "The requested environment backend is not configured.", Remediation: []output.Remediation{{Action: "configure-domain", Hint: "Explicitly bind environment variable storage with one env bind first.", Command: "one env bind"}}}, BACKEND_VERB_NOT_SUPPORTED: {Summary: "The requested environment operation is not supported."}, BACKEND_INTERFACE_MISMATCH: {Summary: "Internal: the dispatched backend failed its capability assertion. Build-side bug; should never reach end users."}, PREFERENCES_FILE_INVALID: {Summary: "The local preferences file could not be read or parsed."}, @@ -208,7 +208,7 @@ var Codes = map[Code]Definition{ ENV_MIGRATE_CONFLICT: {Summary: "Reserved error code from the retired local environment workflow."}, ENV_MIGRATE_PARTIAL: {Summary: "Reserved error code from the retired local environment workflow."}, - INFISICAL_NOT_CONFIGURED: {Summary: "The workspace has no Infisical project binding.", Remediation: []output.Remediation{{Action: "set-first-variable", Hint: "Save the first variable to create and connect an Infisical project.", Command: "one env set "}}}, + INFISICAL_NOT_CONFIGURED: {Summary: "The workspace has no Infisical project binding.", Remediation: []output.Remediation{{Action: "set-first-variable", Hint: "Explicitly bind an Infisical project with one env bind first.", Command: "one env bind"}}}, INFISICAL_AUTH_MISSING: {Summary: "No active Infisical browser session.", Remediation: []output.Remediation{{Action: "login", Command: "one login"}}}, INFISICAL_AUTH_FAILED: {Summary: "The Infisical session was rejected or expired.", Remediation: []output.Remediation{{Action: "login", Command: "one login"}}}, INFISICAL_PROJECT_NOT_FOUND: {Summary: "Infisical project id does not exist or the current account has no access to it."}, diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 7544a1a5..80e924d9 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -40,7 +40,7 @@ "error.SUBPROJECT_NOT_FOUND.message": "The requested project was not found.", "error.SUBPROJECT_NOT_FOUND.hint.0": "Check the project name, or switch to the intended project directory and retry.", "error.BACKEND_NOT_ENABLED.message": "The required deployment or build capability is not configured.", - "error.BACKEND_NOT_ENABLED.hint.0": "A storage project will be created when you save the first environment variable.", + "error.BACKEND_NOT_ENABLED.hint.0": "Explicitly bind environment variable storage with one env bind first.", "error.DEPENDENCIES_NOT_INSTALLED.message": "Local development dependencies are not installed.", "error.ENV_SET_VALUE_REQUIRED.message": "An environment-variable value is required.", "error.ENV_SET_OVERWRITE_REQUIRED.message": "The variable already exists and needs confirmation before it is overwritten.", @@ -126,7 +126,7 @@ "env.scope_project_option": "Project: %s", "env.prompt_overwrite": "%s already exists. Overwrite it?", "env.set_success_remote": "✓ Environment variable %s saved\n Path: %s\n Environment: %s", - "env.set.tip": "Sets one environment variable. Use `one env set KEY` for hidden interactive input, or pass `KEY VALUE` / `KEY=VALUE` in automation.", + "env.set.tip": "Sets one environment variable. Bind the workspace to Infisical with one env bind first; an unbound workspace fails without creating a project. Use `one env set KEY` for hidden interactive input, or pass `KEY VALUE` / `KEY=VALUE` in automation.", "env.flag.project": "Project name or relative path; defaults to the current project", "env.flag.environment": "Environment name (default: dev)", "env.flag.yes": "Confirm overwrites and new environments non-interactively", @@ -166,8 +166,8 @@ "infisical.create_failed": "Could not create the Infisical project; the service returned HTTP %d. Check account permissions or service availability for this response.", "infisical.binding_account_mismatch": "The workspace is bound to another Infisical instance. Check your account or select the project again.", "infisical.binding_instance_mismatch": "The workspace is bound to another Infisical instance. Select the project again.", - "infisical.config_missing": "Environment variable storage has not been set up for this workspace.", - "infisical.binding_missing": "This workspace is not connected to an Infisical project yet.", + "infisical.config_missing": "This workspace is not bound to an Infisical project. Run one env bind first.", + "infisical.binding_missing": "This workspace is not bound to an Infisical project. Run one env bind first.", "env.name_required": "A variable name is required.", "infisical.project_required": "Select an Infisical project.", "infisical.project_response_invalid": "Infisical returned an invalid project response.", @@ -240,12 +240,22 @@ "create.workspace_nested": "You are inside workspace %s. Use one add to add a project, or switch to a directory outside it before creating another workspace.", "create.directory_required": "Choose a target directory, for example one create my-app. To use the current directory, run one create .", "create.name_invalid": "Cannot use %q as the workspace name. Start with a letter or number and use only letters, numbers, hyphens, and underscores.", - "create.infisical_binding_warning": "Environment variable storage is not ready: %v. One will retry when you save the first variable.", + "create.infisical_binding_warning": "Environment variable storage is not ready: %v. Run one env bind to explicitly bind a project.", "env.provider_invalid": "Unsupported environment source %q. Only Infisical is supported.", "env.flag.global": "Manage Infisical shared credentials, including outside a workspace", "env.flag.path": "Shared credential folder (current level only, not recursive)", "env.flag.environment_name": "Environment name", - "env.bind.short": "Select the shared credential project and default environment", + "env.bind.short": "Create or bind an Infisical project", + "env.bind.flag.create": "Create and bind the workspace Infisical project; preserve an existing binding", + "env.bind.create_conflict": "--create cannot be combined with --project-id.", + "env.bind.selection_required": "Run one env bind to select a project interactively; use --create or --project-id in non-interactive mode.", + "env.bind.workspace_env_fixed": "The workspace default environment is fixed at dev; --env on bind is only for --global shared credentials.", + "env.bind.create_option": "Create and bind an Infisical project for this workspace", + "env.bind.select_workspace_project": "Select the workspace Infisical project", + "env.bind.progress": "Binding the Infisical project…", + "env.bind.workspace_success": "✓ Bound Infisical project: %s (%s). Binding saved to %s.", + "env.bind.success": "✓ Bound shared credential project: %s (%s), default environment: %s.", + "env.bind.tip": "Sign in with one login.\n\nRun one env bind to select an existing project or create a workspace project interactively. Use --create to explicitly create and bind storage, or --project-id to bind an existing Secret Manager project. Existing bindings are preserved. Workspace bindings are saved to one.manifest.toml, with default environment dev. Without a binding, set fails instead of creating a project.\n\nRun one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration.", "env.flag.project_id": "Existing Infisical project ID", "env.flag.default_environment": "Default environment", "env.bind.global_required": "Use one env bind --global.", @@ -474,7 +484,7 @@ "error.DOMAIN_NOT_PER_SUBPROJECT.hint.0": "Remove -p / --project and retry", "error.BACKEND_VERB_NOT_SUPPORTED.hint.0": "The current environment variable source cannot perform this operation. Check the command help.", "error.ENV_SET_OVERWRITE_REQUIRED.hint.0": "Pass --yes to confirm overwriting", - "error.INFISICAL_NOT_CONFIGURED.hint.0": "Saving the first variable will create and connect an Infisical project.", + "error.INFISICAL_NOT_CONFIGURED.hint.0": "Explicitly bind an Infisical project with one env bind first.", "error.INFISICAL_PROJECT_NAME_TAKEN.hint.0": "Check the project name conflict in Infisical. One did not bind to an existing project with the same name.", "error.INFISICAL_FOLDER_NOT_FOUND.hint.0": "Check the --env spelling (for example dev, staging, or prod)", "error.INFISICAL_FOLDER_NOT_FOUND.hint.1": "Writing the first variable to a folder creates it automatically", @@ -728,5 +738,7 @@ "upgrade.current": "One CLI %s is up to date (latest stable release: %s).", "upgrade.release_required": "This build (%s) cannot upgrade itself. Only official stable release builds can upgrade. Install the latest release with: %s", "upgrade.failed": "Could not update One CLI at %s: %w\nCheck the network and installation directory permissions, then retry `one upgrade`, or run: %s", - "upgrade.worker_invalid": "The update worker did not return a valid result. Retry `one upgrade`." + "upgrade.worker_invalid": "The update worker did not return a valid result. Retry `one upgrade`.", + "env.bind.revision_conflict": "Workspace configuration has changed. Refresh before binding again.", + "env.bind.request_invalid": "Binding requires a configuration revision and exactly one of creation or an existing project." } diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index f4b533aa..09d19ed1 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -40,7 +40,7 @@ "error.SUBPROJECT_NOT_FOUND.message": "找不到指定项目。", "error.SUBPROJECT_NOT_FOUND.hint.0": "检查项目名称,或切换到目标项目目录后重试。", "error.BACKEND_NOT_ENABLED.message": "所需的部署或构建能力尚未配置。", - "error.BACKEND_NOT_ENABLED.hint.0": "首次保存环境变量时会自动创建存储项目。", + "error.BACKEND_NOT_ENABLED.hint.0": "请先通过 one env bind 显式绑定环境变量存储项目。", "error.DEPENDENCIES_NOT_INSTALLED.message": "本地开发依赖尚未安装。", "error.ENV_SET_VALUE_REQUIRED.message": "需要提供环境变量值。", "error.ENV_SET_OVERWRITE_REQUIRED.message": "变量已存在,需要确认后才能覆盖。", @@ -126,7 +126,7 @@ "env.scope_project_option": "项目:%s", "env.prompt_overwrite": "%s 已存在。要覆盖吗?", "env.set_success_remote": "✓ 已保存环境变量 %s\n 路径:%s\n 环境:%s", - "env.set.tip": "设置一个环境变量。使用 `one env set KEY` 可在交互终端中隐藏输入;自动化可传 `KEY VALUE` 或 `KEY=VALUE`。", + "env.set.tip": "设置一个环境变量。工作区须先通过 one env bind 显式绑定 Infisical;未绑定时直接报错,不会创建项目。使用 `one env set KEY` 可在交互终端中隐藏输入;自动化可传 `KEY VALUE` 或 `KEY=VALUE`。", "env.flag.project": "项目名称或相对路径;默认使用当前项目", "env.flag.environment": "环境名称(默认 dev)", "env.flag.yes": "非交互确认覆盖和创建新环境", @@ -166,8 +166,8 @@ "infisical.create_failed": "无法创建 Infisical 项目,服务返回 HTTP %d。请根据响应检查账号权限或服务状态。", "infisical.binding_account_mismatch": "工作区绑定了不同 Infisical 实例,请检查登录账号或重新选择项目。", "infisical.binding_instance_mismatch": "工作区绑定了不同 Infisical 实例,请重新选择项目。", - "infisical.config_missing": "这个工作区尚未配置环境变量存储。", - "infisical.binding_missing": "这个工作区尚未连接 Infisical 项目。", + "infisical.config_missing": "当前工作区尚未绑定 Infisical 项目,请先运行 one env bind。", + "infisical.binding_missing": "当前工作区尚未绑定 Infisical 项目,请先运行 one env bind。", "env.name_required": "必须提供密钥名。", "infisical.project_required": "必须选择 Infisical 项目", "infisical.project_response_invalid": "Infisical 项目响应无效", @@ -240,12 +240,22 @@ "create.workspace_nested": "当前位于工作区 %s 内。请用 one add 添加项目,或先切换到工作区之外再创建。", "create.directory_required": "请指定要创建的目录,例如 one create my-app;在当前目录创建请使用 one create .。", "create.name_invalid": "工作区名称 %q 不能使用。请以字母或数字开头,只使用字母、数字、连字符和下划线。", - "create.infisical_binding_warning": "环境变量存储尚未准备好:%v。首次保存变量时会重试。", + "create.infisical_binding_warning": "环境变量存储尚未准备好:%v。请运行 one env bind 显式绑定项目。", "env.provider_invalid": "不支持环境变量来源 %q;当前仅支持 Infisical。", "env.flag.global": "管理 Infisical 共享凭据,可在工作区之外使用", "env.flag.path": "共享凭据目录(仅当前层,不递归)", "env.flag.environment_name": "环境名", - "env.bind.short": "选择共享凭据的存放项目和默认环境", + "env.bind.short": "创建或绑定 Infisical 项目", + "env.bind.flag.create": "创建并绑定工作区的 Infisical 项目;已有绑定会保留", + "env.bind.create_conflict": "--create 不能与 --project-id 同时使用。", + "env.bind.selection_required": "请运行 one env bind 交互选择项目;非交互模式请使用 --create 或 --project-id。", + "env.bind.workspace_env_fixed": "工作区默认环境固定为 dev;绑定命令的 --env 仅用于 --global 共享凭据。", + "env.bind.create_option": "创建并绑定当前工作区的 Infisical 项目", + "env.bind.select_workspace_project": "选择工作区的 Infisical 项目", + "env.bind.progress": "正在绑定 Infisical 项目…", + "env.bind.workspace_success": "✓ 已绑定 Infisical 项目:%s(%s)。绑定已保存到 %s。", + "env.bind.success": "✓ 已绑定共享凭据项目:%s(%s),默认环境:%s。", + "env.bind.tip": "先通过 one login 登录。\n\n运行 one env bind 交互选择已有项目或创建工作区项目。使用 --create 显式创建并绑定,或使用 --project-id 绑定已有 Secret Manager 项目。已有绑定会保留,工作区绑定写入 one.manifest.toml,默认环境固定为 dev。未绑定时 set 会报错,不会自动创建项目。\n\n运行 one env bind --global 初始化共享凭据位置。未绑定时自动创建或复用 shared-credentials 项目,默认环境为 dev,无需选择项目或填写 ID。已有绑定会保留;--env 可修改默认环境。共享凭据绑定信息保存在本机配置中。", "env.flag.project_id": "已有 Infisical 项目 ID", "env.flag.default_environment": "默认环境", "env.bind.global_required": "请使用 one env bind --global", @@ -474,7 +484,7 @@ "error.DOMAIN_NOT_PER_SUBPROJECT.hint.0": "去掉 -p / --project 重试", "error.BACKEND_VERB_NOT_SUPPORTED.hint.0": "当前环境变量来源无法执行此操作,请查看命令帮助。", "error.ENV_SET_OVERWRITE_REQUIRED.hint.0": "加 --yes 确认覆盖", - "error.INFISICAL_NOT_CONFIGURED.hint.0": "保存第一个变量时会自动创建并绑定 Infisical 项目。", + "error.INFISICAL_NOT_CONFIGURED.hint.0": "请先通过 one env bind 显式绑定 Infisical 项目。", "error.INFISICAL_PROJECT_NAME_TAKEN.hint.0": "请检查 Infisical 中的项目名称冲突;本次没有绑定到已有的同名项目。", "error.INFISICAL_FOLDER_NOT_FOUND.hint.0": "确认 --env 名是否拼对(dev / staging / prod 等)", "error.INFISICAL_FOLDER_NOT_FOUND.hint.1": "在该 folder 下写入第一个环境变量值时会自动创建", @@ -728,5 +738,7 @@ "upgrade.current": "One CLI %s 已是最新版本(最新稳定版:%s)。", "upgrade.release_required": "当前构建(%s)不支持自更新,仅官方稳定发行版支持。请使用以下命令安装最新发行版:%s", "upgrade.failed": "无法更新 %s 处的 One CLI:%w\n请检查网络和安装目录权限后重试 `one upgrade`,或运行:%s", - "upgrade.worker_invalid": "更新进程未返回有效结果,请重试 `one upgrade`。" + "upgrade.worker_invalid": "更新进程未返回有效结果,请重试 `one upgrade`。", + "env.bind.revision_conflict": "工作区配置已发生变化,请刷新后重新绑定。", + "env.bind.request_invalid": "绑定请求需要配置版本,并且只能选择创建或绑定已有项目。" } diff --git a/packages/cli/internal/transport/cobra/env/bind.go b/packages/cli/internal/transport/cobra/env/bind.go new file mode 100644 index 00000000..63458211 --- /dev/null +++ b/packages/cli/internal/transport/cobra/env/bind.go @@ -0,0 +1,46 @@ +package envcmd + +import ( + "github.com/spf13/cobra" + + environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/prompt" +) + +func runWorkspaceBind(cmd *cobra.Command, deps Dependencies, projectID string, create bool) error { + if projectID == "" && !create && output.CanPrompt() { + projects, err := environmentmodule.Projects(cmd.Context()) + if err != nil { + return err + } + const createChoice = "__create_workspace_project__" + options := []prompt.Option[string]{{Label: i18n.T("env.bind.create_option"), Value: createChoice}} + for _, project := range projects { + options = append(options, prompt.Option[string]{Label: project.Name, Value: project.ID}) + } + choice, err := prompt.Select(i18n.T("env.bind.select_workspace_project"), options) + if err != nil { + return err + } + if choice == createChoice { + create = true + } else { + projectID = choice + } + } + var result *environmentmodule.WorkspaceBindingResult + err := prompt.Spin(i18n.T("env.bind.progress"), func() error { + var err error + result, err = deps.Service.BindWorkspace(cmd.Context(), environmentmodule.BindWorkspaceInput{ + Scope: commandScope(cmd), ProjectID: projectID, Create: create, + }) + return err + }) + if err != nil { + return err + } + output.Emit(workspaceBindOutput{result}) + return nil +} diff --git a/packages/cli/internal/transport/cobra/env/bind_test.go b/packages/cli/internal/transport/cobra/env/bind_test.go new file mode 100644 index 00000000..efeea82c --- /dev/null +++ b/packages/cli/internal/transport/cobra/env/bind_test.go @@ -0,0 +1,156 @@ +package envcmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" + catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" + "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/platform/i18n" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + "github.com/zalando/go-keyring" +) + +func workspaceBindFixture(t *testing.T) (Dependencies, execution.Scope) { + t.Helper() + root := t.TempDir() + if err := workspace.WriteManifest(root, &workspace.Manifest{ + Version: workspace.ManifestVersion, Workspace: &workspace.ManifestWorkspace{ID: "workspace", Name: "demo"}, + }); err != nil { + t.Fatal(err) + } + service, err := environmentmodule.NewService(catalog.Builtin()) + if err != nil { + t.Fatal(err) + } + return Dependencies{Service: service}, execution.NewScope(context.Background(), root) +} + +func TestWorkspaceBindExplicitCreateAndExistingProject(t *testing.T) { + for _, test := range []struct { + name string + args []string + wantCreates int + }{ + {"create", []string{"--create"}, 1}, + {"existing project", []string{"--project-id", "remote"}, 0}, + } { + t.Run(test.name, func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + creates := 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/v2/workspace": + creates++ + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if r.Method != http.MethodPost || body["projectName"] != "demo" { + t.Errorf("unexpected creation: %s %v", r.Method, body) + } + w.Write([]byte(`{"project":{"id":"remote","name":"demo"}}`)) + case "/api/v1/workspace/remote": + w.Write([]byte(`{"workspace":{"id":"remote","name":"Existing","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"},{"slug":"qa"}]}}`)) + case "/api/v3/secrets/raw": + if r.Method != http.MethodGet || r.URL.Query().Get("workspaceId") != "remote" { + t.Errorf("unexpected verification: %s %s", r.Method, r.URL) + } + w.Write([]byte(`{"secrets":[]}`)) + default: + t.Errorf("binding requested unexpected resource: %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + raw, err := 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 != nil { + t.Fatal(err) + } + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Fatal(err) + } + deps, scope := workspaceBindFixture(t) + for range 2 { + cmd := Commands(deps)[0] + cmd.SetContext(execution.WithScope(context.Background(), scope)) + cmd.SetArgs(append([]string{"bind"}, test.args...)) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + } + manifest, err := workspace.ReadManifest(scope.WorkingDirectory()) + if err != nil || manifest.Env == nil || manifest.Env.ProjectID != "remote" || manifest.Env.SiteURL != upstream.URL { + t.Fatalf("binding was not saved: %+v %v", manifest, err) + } + if test.wantCreates == 0 && strings.Join(manifest.Env.Environments, ",") != "dev,qa" { + t.Fatalf("did not use remote environments: %v", manifest.Env.Environments) + } + if creates != test.wantCreates { + t.Fatalf("created %d projects, want %d", creates, test.wantCreates) + } + if location, err := environmentmodule.LoadGlobalLocation(); err != nil || location != nil { + t.Fatalf("workspace binding changed global credentials: %+v %v", location, err) + } + }) + } +} + +func TestUnboundSetFailsBeforePromptInBothLanguages(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) + deps, scope := workspaceBindFixture(t) + before, err := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) + if err != nil { + t.Fatal(err) + } + for _, locale := range []string{"zh-CN", "en-US"} { + if err := i18n.Init(locale); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"set", "API_TOKEN"}, {"set", "API_TOKEN=private-value"}} { + cmd := Commands(deps)[0] + cmd.SilenceErrors, cmd.SilenceUsage = true, true + cmd.SetContext(execution.WithScope(context.Background(), scope)) + cmd.SetArgs(args) + err := cmd.Execute() + var coded interface{ ErrorCode() string } + if !errors.As(err, &coded) || coded.ErrorCode() != "INFISICAL_NOT_CONFIGURED" || !strings.Contains(err.Error(), "one env bind") || strings.Contains(err.Error(), "private-value") { + t.Fatalf("unexpected unbound %s error: %v", locale, err) + } + } + } + after, err := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) + if err != nil || string(before) != string(after) { + t.Fatalf("unbound set changed the manifest: %v", err) + } +} + +func TestWorkspaceBindRejectsAmbiguousFlagsBeforeRemoteAccess(t *testing.T) { + for _, args := range [][]string{ + {"bind", "--create", "--project-id", "remote"}, + {"bind", "--create", "--env", "prod"}, + {"bind", "--create", "--path", "/"}, + } { + cmd := Commands(Dependencies{})[0] + cmd.SilenceErrors, cmd.SilenceUsage = true, true + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil { + t.Fatalf("accepted ambiguous binding flags: %v", args) + } + } +} diff --git a/packages/cli/internal/transport/cobra/env/global.go b/packages/cli/internal/transport/cobra/env/global.go index ab7acede..8c613dea 100644 --- a/packages/cli/internal/transport/cobra/env/global.go +++ b/packages/cli/internal/transport/cobra/env/global.go @@ -17,35 +17,45 @@ func configureGlobal(parent *cobra.Command, deps Dependencies) { i18n.MarkFlagUsage(parent, "path", "env.flag.path") parent.Flags().String("env", "", i18n.T("env.flag.environment_name")) i18n.MarkFlagUsage(parent, "env", "env.flag.environment_name") - bind := &cobra.Command{Use: "bind", Short: i18n.T("env.bind.short"), Args: i18n.NoArgs} + bind := &cobra.Command{ + Use: "bind", + Short: i18n.T("env.bind.short"), + Long: i18n.T("env.bind.tip"), + Example: " one env bind\n one env bind --create\n one env bind --project-id PROJECT_ID\n one env bind --global", + Args: i18n.NoArgs, + } i18n.MarkShort(bind, "env.bind.short") + i18n.MarkLong(bind, "env.bind.tip") bind.Flags().String("project-id", "", i18n.T("env.flag.project_id")) i18n.MarkFlagUsage(bind, "project-id", "env.flag.project_id") + bind.Flags().Bool("create", false, i18n.T("env.bind.flag.create")) + i18n.MarkFlagUsage(bind, "create", "env.bind.flag.create") bind.Flags().String("env", "", i18n.T("env.flag.default_environment")) i18n.MarkFlagUsage(bind, "env", "env.flag.default_environment") bind.RunE = func(c *cobra.Command, _ []string) error { global, _ := c.Flags().GetBool("global") - if !global { - return i18n.Errorf("env.bind.global_required") - } id, _ := c.Flags().GetString("project-id") env, _ := c.Flags().GetString("env") - if id == "" && output.CanPrompt() { - ps, e := remote.Projects(c.Context()) - if e != nil { - return e - } - if len(ps) == 0 { - return i18n.Errorf("env.bind.no_projects") + create, _ := c.Flags().GetBool("create") + if create && id != "" { + return i18n.Errorf("env.bind.create_conflict") + } + if !global { + if c.Flags().Changed("path") { + return i18n.Errorf("env.path_global_required") } - options := []prompt.Option[string]{} - for _, p := range ps { - options = append(options, prompt.Option[string]{Label: p.Name, Value: p.ID}) + if c.Flags().Changed("env") { + return i18n.Errorf("env.bind.workspace_env_fixed") } - id, e = prompt.Select(i18n.T("env.bind.select_project"), options) - if e != nil { - return e + return runWorkspaceBind(c, deps, id, create) + } + if id == "" { + location, err := remote.BindDefaultGlobal(c.Context(), env) + if err != nil { + return err } + output.Emit(bindOutput{location}) + return nil } if env == "" && output.CanPrompt() { p, e := remote.Project(c.Context(), id) @@ -68,7 +78,7 @@ func configureGlobal(parent *cobra.Command, deps Dependencies) { if e != nil { return e } - output.Emit(l) + output.Emit(bindOutput{l}) return nil } unset := &cobra.Command{Use: "unset ", Short: i18n.T("env.unset.short"), Args: i18n.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { diff --git a/packages/cli/internal/transport/cobra/env/global_test.go b/packages/cli/internal/transport/cobra/env/global_test.go new file mode 100644 index 00000000..6cd5fa9b --- /dev/null +++ b/packages/cli/internal/transport/cobra/env/global_test.go @@ -0,0 +1,189 @@ +package envcmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" + remote "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/helpui" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + "github.com/zalando/go-keyring" +) + +func TestGlobalBindCreatesReusesAndPreservesStorage(t *testing.T) { + for _, test := range []struct { + name string + args []string + defaultExists bool + savedCustom bool + forbidden bool + wantProject string + wantEnv string + wantCreates int + wantError bool + }{ + {name: "create default", wantProject: "shared", wantEnv: "dev", wantCreates: 1}, + {name: "reuse default", defaultExists: true, wantProject: "shared", wantEnv: "dev"}, + {name: "preserve custom binding", savedCustom: true, wantProject: "custom", wantEnv: "prod"}, + {name: "create with requested environment", args: []string{"--env", "prod"}, wantProject: "shared", wantEnv: "prod", wantCreates: 1}, + {name: "reuse with requested environment", defaultExists: true, args: []string{"--env", "prod"}, wantProject: "shared", wantEnv: "prod"}, + {name: "change saved environment", savedCustom: true, args: []string{"--env", "dev"}, wantProject: "custom", wantEnv: "dev"}, + {name: "explicit project", args: []string{"--project-id", "custom", "--env", "prod"}, wantProject: "custom", wantEnv: "prod"}, + {name: "invalid environment stays unbound", defaultExists: true, args: []string{"--env", "missing"}, wantError: true}, + {name: "invalid environment preserves binding", savedCustom: true, args: []string{"--env", "missing"}, wantProject: "custom", wantEnv: "prod", wantError: true}, + {name: "creation permission denied", forbidden: true, wantCreates: 1, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + creates, lists := 0, 0 + shared := remote.RemoteProject{ + ID: "shared", Name: "shared-credentials", Type: "secret-manager", OrganizationID: "org", + Environments: []infisical.RemoteEnvironment{{Slug: "dev"}, {Slug: "prod"}}, + } + custom := shared + custom.ID, custom.Name = "custom", "Custom" + 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" { + t.Error("request did not use the signed-in session") + } + switch r.URL.Path { + case "/api/v1/workspace": + lists++ + projects := []remote.RemoteProject{custom} + if test.defaultExists { + projects = append(projects, shared) + } + json.NewEncoder(w).Encode(map[string]any{"workspaces": projects}) + case "/api/v2/workspace": + creates++ + if test.forbidden { + http.Error(w, "private-upstream-information", http.StatusForbidden) + return + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if r.Method != http.MethodPost || body["projectName"] != shared.Name || body["type"] != shared.Type { + t.Errorf("unexpected project creation: %s %v", r.Method, body) + } + json.NewEncoder(w).Encode(map[string]any{"project": shared}) + case "/api/v1/workspace/shared": + json.NewEncoder(w).Encode(map[string]any{"workspace": shared}) + case "/api/v1/workspace/custom": + json.NewEncoder(w).Encode(map[string]any{"workspace": custom}) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + http.NotFound(w, r) + } + })) + defer upstream.Close() + raw, err := 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 != nil { + t.Fatal(err) + } + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Fatal(err) + } + if test.savedCustom { + if _, err := remote.BindGlobal(context.Background(), "custom", "prod"); err != nil { + t.Fatal(err) + } + } + for range 2 { + cmd := Commands(Dependencies{})[0] + cmd.SilenceUsage, cmd.SilenceErrors = true, true + cmd.SetArgs(append([]string{"bind", "--global"}, test.args...)) + err := cmd.Execute() + if (err != nil) != test.wantError { + t.Fatalf("bind error = %v, wantError = %v", err, test.wantError) + } + if test.forbidden { + var coded interface{ ErrorCode() string } + if !errors.As(err, &coded) || coded.ErrorCode() != "INFISICAL_PROJECT_CREATE_FORBIDDEN" || strings.Contains(err.Error(), "private-upstream-information") { + t.Fatalf("creation failure lost its error code or leaked upstream data: %v", err) + } + } + location, err := remote.LoadGlobalLocation() + if err != nil { + t.Fatal(err) + } + if test.wantProject == "" { + if location != nil { + t.Fatalf("saved binding after failed setup: %+v", location) + } + } else if location == nil || location.ProjectID != test.wantProject || location.DefaultEnvironment != test.wantEnv || location.UserID != "user" || location.OrganizationID != "org" || location.SiteURL != upstream.URL { + t.Fatalf("unexpected saved binding: %+v", location) + } + if test.wantError { + break + } + } + if creates != test.wantCreates { + t.Fatalf("created %d projects, want %d", creates, test.wantCreates) + } + if (test.savedCustom || test.wantProject == "custom") && lists != 0 { + t.Fatal("saved or explicit binding searched for the default project") + } + }) + } +} + +func TestGlobalBindRequiresLogin(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for _, args := range [][]string{{"bind", "--global"}} { + cmd := Commands(Dependencies{})[0] + cmd.SilenceUsage, cmd.SilenceErrors = true, true + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil { + t.Fatalf("accepted binding without scope or login: %v", args) + } + if location, err := remote.LoadGlobalLocation(); err != nil || location != nil { + t.Fatalf("created a binding without scope or login: %+v %v", location, err) + } + } +} + +func TestGlobalBindHelpRefreshesInBothLanguages(t *testing.T) { + t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) + cmd := Commands(Dependencies{})[0] + cmd.SetHelpFunc(helpui.Render) + bind, _, err := cmd.Find([]string{"bind"}) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct{ locale, want, absent string }{ + {"en-US", "No project selection or ID is required", "无需选择项目或填写 ID"}, + {"zh-CN", "无需选择项目或填写 ID", "No project selection or ID is required"}, + {"en-US", "No project selection or ID is required", "无需选择项目或填写 ID"}, + } { + if err := i18n.Init(test.locale); err != nil { + t.Fatal(err) + } + i18n.RefreshTree(cmd) + var out bytes.Buffer + bind.SetOut(&out) + if err := bind.Help(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), test.want) || strings.Contains(out.String(), test.absent) || strings.Contains(out.String(), "env.bind.") { + t.Fatalf("help did not refresh to %s: %s", test.locale, out.String()) + } + } +} diff --git a/packages/cli/internal/transport/cobra/env/render.go b/packages/cli/internal/transport/cobra/env/render.go index fb62507d..cfd03d7d 100644 --- a/packages/cli/internal/transport/cobra/env/render.go +++ b/packages/cli/internal/transport/cobra/env/render.go @@ -36,6 +36,38 @@ func (r summaryOutput) RenderTTY(w io.Writer) { type listOutput struct{ *environmentmodule.ListResult } +type bindOutput struct { + *environmentmodule.GlobalLocation +} + +type workspaceBindOutput struct { + *environmentmodule.WorkspaceBindingResult +} + +func (r workspaceBindOutput) RenderTTY(w io.Writer) { + if r.WorkspaceBindingResult == nil { + return + } + if r.Created { + if r.RequestedName != "" { + fmt.Fprintln(w, i18n.Tf("env.project_renamed", r.RequestedName, r.ProjectName)) + } + fmt.Fprintln(w, i18n.Tf("env.project_created", r.ProjectName)) + } + name := r.ProjectName + if name == "" { + name = r.ProjectID + } + fmt.Fprintln(w, i18n.Tf("env.bind.workspace_success", name, r.ProjectID, r.WrittenTo)) +} + +func (r bindOutput) RenderTTY(w io.Writer) { + if r.GlobalLocation == nil { + return + } + fmt.Fprintln(w, i18n.Tf("env.bind.success", r.ProjectName, r.ProjectID, r.DefaultEnvironment)) +} + func (r listOutput) RenderTTY(w io.Writer) { if r.ListResult == nil { return diff --git a/packages/cli/internal/transport/cobra/env/render_test.go b/packages/cli/internal/transport/cobra/env/render_test.go index 2f94a73a..24562fa6 100644 --- a/packages/cli/internal/transport/cobra/env/render_test.go +++ b/packages/cli/internal/transport/cobra/env/render_test.go @@ -2,6 +2,7 @@ package envcmd import ( "bytes" + "encoding/json" "strings" "testing" @@ -9,6 +10,36 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) +func TestGlobalBindResultShowsStorageInBothLanguagesAndPreservesJSON(t *testing.T) { + t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) + location := &environmentmodule.GlobalLocation{ + SiteURL: "https://secrets.example.com", UserID: "user", OrganizationID: "org", + ProjectID: "shared-project", ProjectName: "shared-credentials", DefaultEnvironment: "dev", + } + result := bindOutput{location} + for _, test := range []struct{ locale, want string }{ + {"zh-CN", "✓ 已绑定共享凭据项目:shared-credentials(shared-project),默认环境:dev。\n"}, + {"en-US", "✓ Bound shared credential project: shared-credentials (shared-project), default environment: dev.\n"}, + } { + if err := i18n.Init(test.locale); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + result.RenderTTY(&out) + if out.String() != test.want { + t.Fatalf("%s binding output = %q, want %q", test.locale, out.String(), test.want) + } + } + want, err := json.Marshal(location) + if err != nil { + t.Fatal(err) + } + got, err := json.Marshal(result) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("binding JSON changed: %s, error: %v", got, err) + } +} + func TestSetResultIdentifiesActualRemoteProjectInBothLanguages(t *testing.T) { t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) for _, locale := range []string{"zh-CN", "en-US"} { diff --git a/packages/cli/internal/transport/cobra/env/set.go b/packages/cli/internal/transport/cobra/env/set.go index 769c61b4..9dea6c24 100644 --- a/packages/cli/internal/transport/cobra/env/set.go +++ b/packages/cli/internal/transport/cobra/env/set.go @@ -63,11 +63,7 @@ func newSetCmd(deps Dependencies) *cobra.Command { Plan: plan, Key: key, Value: value, Overwrite: yes, } var result *environmentmodule.SetResult - progress := i18n.T("env.saving") - if plan.NeedsBinding { - progress = i18n.T("env.initializing") - } - err = prompt.Spin(progress, func() error { + err = prompt.Spin(i18n.T("env.saving"), func() error { var setErr error result, setErr = deps.Service.Set(cmd.Context(), input) return setErr diff --git a/packages/cli/internal/transport/http/handlers_workspace_bind_test.go b/packages/cli/internal/transport/http/handlers_workspace_bind_test.go new file mode 100644 index 00000000..3786690f --- /dev/null +++ b/packages/cli/internal/transport/http/handlers_workspace_bind_test.go @@ -0,0 +1,62 @@ +package serve + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" +) + +func TestWorkspaceBindingUsesSelectedWorkspaceAndRejectsStaleRequests(t *testing.T) { + registry := newRegistryService(t) + launch := seedRegistryWorkspace(t, "launch", "Launch", "") + root := seedRegistryWorkspace(t, "selected", "Selected", "") + manifest, err := workspacecore.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + manifest.Env = &workspacecore.EnvironmentConfig{ProjectID: "selected-project", Environments: []string{"dev"}} + if err := workspacecore.WriteManifest(root, manifest); err != nil { + t.Fatal(err) + } + selected := observeRegistryWorkspace(t, registry, root) + handler := newRegistryMux(t, launch, registry) + before := snapshotRepositoryTree(t, launch) + selectedBefore := snapshotRepositoryTree(t, root) + _, revision, err := workspacecore.ReadManifestSnapshot(root) + if err != nil { + t.Fatal(err) + } + path := "/api/workspaces/" + selected.EntryID + "/environment/bind" + for _, test := range []struct { + body string + status int + }{ + {`{"create":true}`, http.StatusBadRequest}, + {`{"revision":"stale","create":true}`, http.StatusConflict}, + {fmt.Sprintf(`{"revision":%q,"create":true,"projectId":"other"}`, revision), http.StatusBadRequest}, + {fmt.Sprintf(`{"revision":%q,"create":false}`, revision), http.StatusBadRequest}, + {fmt.Sprintf(`{"revision":%q,"create":true}`, revision), http.StatusOK}, + } { + response := registryRequest(t, handler, http.MethodPost, path, strings.NewReader(test.body)) + if response.Code != test.status { + t.Fatalf("%s: status=%d body=%s", test.body, response.Code, response.Body.String()) + } + if test.status == http.StatusOK && !strings.Contains(response.Body.String(), `"project_id": "selected-project"`) { + t.Fatalf("bound wrong workspace: %s", response.Body.String()) + } + assertRepositoryUnchanged(t, launch, before) + assertRepositoryUnchanged(t, root, selectedBefore) + } + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"create":true}`)) + request.Host = registryTestHost + request.Header.Set("Origin", "http://external.example") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("cross-origin binding status=%d", response.Code) + } +} diff --git a/packages/cli/internal/transport/http/handlers_workspace_mutate.go b/packages/cli/internal/transport/http/handlers_workspace_mutate.go index 0788b0f8..bbe65209 100644 --- a/packages/cli/internal/transport/http/handlers_workspace_mutate.go +++ b/packages/cli/internal/transport/http/handlers_workspace_mutate.go @@ -3,12 +3,14 @@ package serve import ( "errors" "net/http" + "strings" "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" manifestapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/manifest" workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) func registerWorkspaceMutateRoutes(mux *http.ServeMux, opts MuxOpts) { @@ -20,6 +22,7 @@ func registerWorkspaceMutateRoutes(mux *http.ServeMux, opts MuxOpts) { handleInitializeWorkspaceEnvironmentBackend(opts), ) mux.HandleFunc("PUT /workspace/manifest", handlePutWorkspaceManifest(opts)) + mux.HandleFunc("POST /workspace/environment/bind", handleBindWorkspaceEnvironment(opts)) mux.HandleFunc("POST /workspace/manifest/preview", handlePreviewWorkspaceManifest(opts)) // Keep the former repository-mutation paths stable for older Dashboard @@ -32,6 +35,38 @@ func registerWorkspaceMutateRoutes(mux *http.ServeMux, opts MuxOpts) { } } +func handleBindWorkspaceEnvironment(opts MuxOpts) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + setNoStore(w) + if opts.WorkspaceRoot == "" { + writeNoWorkspace(w) + return + } + var input struct { + Revision string `json:"revision"` + ProjectID string `json:"projectId"` + Create bool `json:"create"` + } + if err := decodeJSON(r, &input); err != nil { + writeBadPayload(w, err.Error()) + return + } + if strings.TrimSpace(input.Revision) == "" || (input.Create == (strings.TrimSpace(input.ProjectID) != "")) { + writeBadPayload(w, i18n.T("env.bind.request_invalid")) + return + } + binding, err := opts.EnvironmentService.BindWorkspace(r.Context(), environmentmodule.BindWorkspaceInput{ + Scope: execution.NewScope(r.Context(), opts.WorkspaceRoot), Revision: input.Revision, + ProjectID: input.ProjectID, Create: input.Create, + }) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, binding) + } +} + func handleInitializeWorkspaceEnvironmentBackend(opts MuxOpts) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if opts.WorkspaceRoot == "" { diff --git a/packages/cli/internal/transport/http/handlers_workspaces.go b/packages/cli/internal/transport/http/handlers_workspaces.go index 749af1b9..fca6564b 100644 --- a/packages/cli/internal/transport/http/handlers_workspaces.go +++ b/packages/cli/internal/transport/http/handlers_workspaces.go @@ -24,6 +24,8 @@ func registerWorkspacesRoutes(mux *http.ServeMux, opts MuxOpts) { handleResolvedWorkspaceRead(opts, handleGetWorkspaceOverview)) mux.HandleFunc("GET /workspaces/{entryId}/environment", handleResolvedWorkspaceRead(opts, handleGetWorkspaceEnvironment)) + mux.HandleFunc("POST /workspaces/{entryId}/environment/bind", + handleResolvedWorkspace(opts, handleBindWorkspaceEnvironment)) mux.HandleFunc("POST /workspaces/{entryId}/projects", handleResolvedWorkspace(opts, handleCreateProject)) mux.HandleFunc("GET /workspaces/{entryId}/projects/{name}", diff --git a/packages/cli/internal/transport/http/responses.go b/packages/cli/internal/transport/http/responses.go index e5a9c648..94ab1eba 100644 --- a/packages/cli/internal/transport/http/responses.go +++ b/packages/cli/internal/transport/http/responses.go @@ -81,6 +81,8 @@ func statusForCode(code string) int { return http.StatusNotFound case string(cliErrors.TARGET_EXISTS): return http.StatusConflict + case string(cliErrors.SERVE_MANIFEST_CONFLICT): + return http.StatusConflict case string(cliErrors.INVALID_NAME), string(cliErrors.TEMPLATE_REQUIRED), string(cliErrors.SUBPROJECT_NAME_REQUIRED): return http.StatusBadRequest case string(cliErrors.ENV_SET_OVERWRITE_REQUIRED): diff --git a/packages/cli/testdata/reference/help/env.txt b/packages/cli/testdata/reference/help/env.txt index a3814e7a..d8409193 100644 --- a/packages/cli/testdata/reference/help/env.txt +++ b/packages/cli/testdata/reference/help/env.txt @@ -6,7 +6,7 @@ USAGE one env [flags] SUBCOMMANDS - bind Select the shared credential project and default environment + bind Create or bind an Infisical project list List environment-variable names set Write a variable to Infisical unset Delete an Infisical environment variable diff --git a/packages/cli/testdata/reference/help/env_bind.txt b/packages/cli/testdata/reference/help/env_bind.txt index 4e909a42..11a81df5 100644 --- a/packages/cli/testdata/reference/help/env_bind.txt +++ b/packages/cli/testdata/reference/help/env_bind.txt @@ -1,11 +1,25 @@ DESCRIPTION -Select the shared credential project and default environment +Create or bind an Infisical project USAGE one env bind [flags] +EXAMPLES + one env bind + one env bind --create + one env bind --project-id PROJECT_ID + one env bind --global + +TIPS +Sign in with one login. + +Run one env bind to select an existing project or create a workspace project interactively. Use --create to explicitly create and bind storage, or --project-id to bind an existing Secret Manager project. Existing bindings are preserved. Workspace bindings are saved to one.manifest.toml, with default environment dev. Without a binding, set fails instead of creating a project. + +Run one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration. + COMMON OPTIONS + --create Create and bind the workspace Infisical project; preserve an existing binding --env Default environment --global Manage Infisical shared credentials, including outside a workspace -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/env_set.txt b/packages/cli/testdata/reference/help/env_set.txt index 3d6a55aa..64cc8b55 100644 --- a/packages/cli/testdata/reference/help/env_set.txt +++ b/packages/cli/testdata/reference/help/env_set.txt @@ -6,7 +6,7 @@ USAGE one env set [VALUE] [flags] TIPS -Sets one environment variable. Use `one env set KEY` for hidden interactive input, or pass `KEY VALUE` / `KEY=VALUE` in automation. +Sets one environment variable. Bind the workspace to Infisical with one env bind first; an unbound workspace fails without creating a project. Use `one env set KEY` for hidden interactive input, or pass `KEY VALUE` / `KEY=VALUE` in automation. COMMON OPTIONS --env Environment name (default: dev) diff --git a/packages/cli/tests/e2e/e2e_helpers_test.go b/packages/cli/tests/e2e/e2e_helpers_test.go index 5d04796c..7c0d3559 100644 --- a/packages/cli/tests/e2e/e2e_helpers_test.go +++ b/packages/cli/tests/e2e/e2e_helpers_test.go @@ -329,3 +329,20 @@ func bootstrapWorkspace(t *testing.T, tmp, name string) string { } return target } + +// setWorkspaceBindingFixture supplies a saved binding for tests that stop +// before authentication or remote access, such as input validation and prompts. +func setWorkspaceBindingFixture(t *testing.T, workspaceRoot string) { + t.Helper() + manifest := readManifest(t, workspaceRoot) + manifest["env"] = map[string]any{"infisical": map[string]any{ + "siteUrl": "http://127.0.0.1:1", "projectId": "test-project", "environments": []string{"dev"}, + }} + raw, err := toml.Marshal(manifest) + if err != nil { + t.Fatalf("encode bound workspace fixture: %v", err) + } + if err := os.WriteFile(filepath.Join(workspaceRoot, "one.manifest.toml"), raw, 0o644); err != nil { + t.Fatalf("write bound workspace fixture: %v", err) + } +} diff --git a/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go b/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go index 51143113..84940137 100644 --- a/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go +++ b/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go @@ -23,6 +23,7 @@ func TestE2E_EnvSetHidesValueBeforeScopeCancellation(t *testing.T) { if _, stderr, code := runBinaryIn(t, ws, "add", "react-spa", "--name", "web", "--yes", "-o", "json"); code != 0 { t.Fatalf("add failed: exit=%d stderr=%s", code, stderr) } + setWorkspaceBindingFixture(t, ws) bin := binaryPath(t) cmd := exec.Command(bin, "env", "set", "TEST_KEY", "-o", "text") diff --git a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go index 08576854..f5124409 100644 --- a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go +++ b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go @@ -167,6 +167,16 @@ func TestSnapshot_E2E_EnvSummaryRequiresExplicitValue(t *testing.T) { t.Fatalf("unexpected env summary: %v", summary) } + _, stderr, code = runBinaryIn(t, ws, "env", "set", "TEST_KEY=private-value", "-o", "json") + if code == 0 { + t.Fatal("unbound workspace should reject variable writes") + } + unboundErr := mustParseJSON(t, firstJSONLine(stderr)) + if unboundErr["error"].(map[string]any)["code"] != "INFISICAL_NOT_CONFIGURED" || !strings.Contains(stderr, "one env bind") || strings.Contains(stderr, "private-value") { + t.Fatalf("unexpected unbound-workspace error: %v", unboundErr) + } + setWorkspaceBindingFixture(t, ws) + _, stderr, code = runBinaryIn(t, ws, "env", "set", "TEST_KEY", "-o", "json") if code == 0 { t.Fatal("non-interactive hidden-value form should require an explicit value") From b4034d7ab1a3ed803f5b02da3839695f1fb03eba Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Thu, 1 Oct 2026 16:47:46 +0800 Subject: [PATCH 2/2] fix(env): validate binding sessions and global create flags --- apps/docs/content/docs/en/env-vars.md | 4 +- apps/docs/content/docs/zh/env-vars.md | 4 +- .../internal/adapters/env/infisical/global.go | 17 +- .../env/infisical/global_session_test.go | 77 ++++++++ .../internal/adapters/env/infisical/init.go | 103 ++++++----- .../environment/binding_session_test.go | 174 ++++++++++++++++++ .../modules/environment/binding_test.go | 1 + .../internal/modules/environment/target.go | 8 +- .../internal/platform/i18n/locales/en-US.json | 8 +- .../internal/platform/i18n/locales/zh-CN.json | 8 +- .../platform/infisicalsession/commit_test.go | 90 +++++++++ .../platform/infisicalsession/session.go | 17 ++ .../internal/transport/cobra/env/global.go | 3 + .../transport/cobra/env/global_test.go | 58 ++++++ .../cli/testdata/reference/help/env_bind.txt | 4 +- 15 files changed, 507 insertions(+), 69 deletions(-) create mode 100644 packages/cli/internal/adapters/env/infisical/global_session_test.go create mode 100644 packages/cli/internal/modules/environment/binding_session_test.go create mode 100644 packages/cli/internal/platform/infisicalsession/commit_test.go diff --git a/apps/docs/content/docs/en/env-vars.md b/apps/docs/content/docs/en/env-vars.md index 29c740f7..5316a957 100644 --- a/apps/docs/content/docs/en/env-vars.md +++ b/apps/docs/content/docs/en/env-vars.md @@ -64,7 +64,7 @@ With an `[env.infisical]` binding, `one run` and `one exec` fetch variables for ## Shared credentials -Shared credentials are independent of workspaces. Run `one env bind --global` to create or reuse the default storage project while preserving any saved binding. Use `--project-id` to bind another existing project. Browse names with `one env list --global --env dev --path /`, and inject an explicit scope with `one exec --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/). +Shared credentials are independent of workspaces. Run `one env bind --global` to create or reuse the default storage project while preserving any saved binding. `--create` is only available for workspace binding and cannot be combined with `--global`. Use `--project-id` to bind another existing project. Browse names with `one env list --global --env dev --path /`, and inject an explicit scope with `one exec --global --env dev --path /folder -- command`. See [login and shared credentials](/en/docs/login/). ## Common errors @@ -81,6 +81,8 @@ Shared credentials are independent of workspaces. Run `one env bind --global` to See [Manifest v2](/en/docs/manifest/) for the configuration structure and [the walkthrough](/en/tutorials/env-vars/) for a first setup. +If the login changes during workspace binding, One refuses to save the configuration. If a remote project was already created, the error includes its ID; confirm the account and retry with `--project-id` to connect that project. + ## Workspaces with the same name `one env bind --create` defaults to the workspace name. On a name conflict, One adds a short suffix and displays the actual name. Identically named workspaces do not share variables merely because of their names: writes target the stored `env.infisical.projectId`. Copying or cloning a configuration that already contains a binding reuses that remote project. Failed variable writes preserve the existing binding. diff --git a/apps/docs/content/docs/zh/env-vars.md b/apps/docs/content/docs/zh/env-vars.md index ad101dda..c152a8f5 100644 --- a/apps/docs/content/docs/zh/env-vars.md +++ b/apps/docs/content/docs/zh/env-vars.md @@ -64,7 +64,7 @@ one run dev --env dev ## 全局共享凭据 -共享凭据独立于工作区。通过 `one env bind --global` 自动创建或复用默认存储项目,已有绑定会保留;也可以通过 `--project-id` 绑定其他已有项目。使用 `one env list --global --env dev --path /` 查看名称,使用 `one exec --global --env dev --path /folder -- command` 注入明确作用域。详见[登录与共享凭据](/zh/docs/login/)。 +共享凭据独立于工作区。通过 `one env bind --global` 自动创建或复用默认存储项目,已有绑定会保留;`--create` 仅用于工作区绑定,不能与 `--global` 同时使用;也可以通过 `--project-id` 绑定其他已有项目。使用 `one env list --global --env dev --path /` 查看名称,使用 `one exec --global --env dev --path /folder -- command` 注入明确作用域。详见[登录与共享凭据](/zh/docs/login/)。 ## 常见错误 @@ -81,6 +81,8 @@ one run dev --env dev 配置结构参阅 [Manifest v2](/zh/docs/manifest/),初次配置参阅[操作教程](/zh/tutorials/env-vars/)。 +工作区绑定期间如果登录状态变化,One 会拒绝保存配置。若远程项目已创建,错误会返回项目 ID;确认账号后可通过 `--project-id` 重试连接该项目。 + ## 同名工作区 `one env bind --create` 默认使用工作区名称;名称冲突时自动添加短后缀,并显示实际名称。不同工作区不会仅因同名而共用变量,写入目标由 `env.infisical.projectId` 确定。复制或克隆包含绑定的配置会继续使用同一远程项目。变量保存失败不会改变已有绑定。 diff --git a/packages/cli/internal/adapters/env/infisical/global.go b/packages/cli/internal/adapters/env/infisical/global.go index 3ab3ca02..844996d7 100644 --- a/packages/cli/internal/adapters/env/infisical/global.go +++ b/packages/cli/internal/adapters/env/infisical/global.go @@ -66,6 +66,12 @@ func Project(ctx context.Context, id string) (*RemoteProject, error) { } return projectFor(ctx, s, id) } + +// ProjectWithSession keeps metadata reads scoped to the binding's session. +func ProjectWithSession(ctx context.Context, s *session.Session, id string) (*RemoteProject, error) { + 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") @@ -137,20 +143,15 @@ func bindGlobalFor(ctx context.Context, s *session.Session, projectID, env strin 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 { + if e = session.WithUnchanged(s, func() error { + return fsutil.WriteAtomic(file, append(data, '\n'), 0600) + }); e != nil { return nil, e } return location, nil diff --git a/packages/cli/internal/adapters/env/infisical/global_session_test.go b/packages/cli/internal/adapters/env/infisical/global_session_test.go new file mode 100644 index 00000000..583c867f --- /dev/null +++ b/packages/cli/internal/adapters/env/infisical/global_session_test.go @@ -0,0 +1,77 @@ +package infisical + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + "github.com/zalando/go-keyring" +) + +func TestGlobalBindingPreservesSavedLocationWhenSessionChanges(t *testing.T) { + for _, change := range []string{"logout", "account"} { + t.Run(change, func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var original session.Session + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspace/remote" || r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("unexpected metadata request: %s", r.URL) + } + if change == "logout" { + if err := session.Logout(); err != nil { + t.Error(err) + } + } else { + current := original + current.UserID = "another-user" + raw, _ := json.Marshal(current) + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Error(err) + } + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"workspace":{"id":"remote","name":"Shared","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`) + })) + defer upstream.Close() + original = session.Session{Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"} + raw, _ := json.Marshal(original) + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Fatal(err) + } + path, err := session.ConfigPath("global-env.json") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + before := []byte(`{"projectId":"previous","defaultEnvironment":"prod"}`) + if err := os.WriteFile(path, before, 0600); err != nil { + t.Fatal(err) + } + _, err = BindGlobal(context.Background(), "remote", "dev") + wantCode := "INFISICAL_AUTH_FAILED" + if change == "logout" { + wantCode = "INFISICAL_AUTH_MISSING" + } + var coded *output.Error + if !errors.As(err, &coded) || coded.Code != wantCode { + t.Fatalf("unexpected session failure: %v", err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatalf("changed session overwrote the saved location: %v", err) + } + }) + } +} diff --git a/packages/cli/internal/adapters/env/infisical/init.go b/packages/cli/internal/adapters/env/infisical/init.go index 87459a1b..d8fc1027 100644 --- a/packages/cli/internal/adapters/env/infisical/init.go +++ b/packages/cli/internal/adapters/env/infisical/init.go @@ -13,6 +13,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" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" ) @@ -35,6 +36,9 @@ type InitInput struct { // Note: skipping verify also disables auto-create — the resolved // projectId must be supplied explicitly. SkipVerify bool + // Session pins all remote work to the caller's snapshot. When omitted, Init + // captures the current session once before contacting Infisical. + Session *session.Session // BeforeWrite rejects stale Dashboard requests after remote work and before // publishing any local changes. The caller holds the manifest lock. BeforeWrite func() error @@ -97,27 +101,33 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e authStatus := "skipped" created := false + if cfg.ProjectID == "" && in.SkipVerify { + return nil, cliErrors.New(cliErrors.INFISICAL_NOT_CONFIGURED, + i18n.T("infisical.init.verify_conflict")) + } + current := in.Session + var client *Client + if !in.SkipVerify { + if current == nil { + current, err = session.Require() + if err != nil { + return nil, err + } + } + cfg.SiteURL = current.SiteURL + client, err = NewClient(ctx, cfg, &Credentials{AccessToken: current.Token}) + if err != nil { + return nil, err + } + } // Auto-create branch (Branch 3). Triggered only when we still have no // projectId AND we're allowed to talk to the network. if cfg.ProjectID == "" { - if in.SkipVerify { - return nil, cliErrors.New(cliErrors.INFISICAL_NOT_CONFIGURED, - i18n.T("infisical.init.verify_conflict")) - } desiredName, err := resolveProjectName(projectRoot, cfg.ProjectName) if err != nil { return nil, err } - creds, siteURL, err := sessionCredentials() - if err != nil { - return nil, err - } - cfg.SiteURL = siteURL - client, err := NewClient(ctx, cfg, creds) - if err != nil { - return nil, err - } id, resolvedName, err := createWithRetryContext(ctx, client, desiredName) if err != nil { return nil, err @@ -126,50 +136,42 @@ func Init(ctx context.Context, projectRoot string, in InitInput) (*InitResult, e cfg.ProjectName = resolvedName authStatus = "created" created = true - if in.BeforeWrite != nil { - if err := in.BeforeWrite(); err != nil { - return nil, bindingWriteError(projectRoot, cfg, err) - } - } - - // Back-fill the manifest's workspace identity. New scaffolds set - // workspace at create time; older workspaces (or those that lost the - // field) get it written here so subsequent initialization calls and any - // future identity-aware command can rely on it. - if err := ensureManifestProject(projectRoot, resolvedName); err != nil { - return nil, bindingWriteError(projectRoot, cfg, err) - } } else if !in.SkipVerify { // Branch 1 / Branch-2-rewrite: validate the explicit / cached id. - creds, siteURL, err := sessionCredentials() - if err != nil { - return nil, err - } - cfg.SiteURL = siteURL - client, err := NewClient(ctx, cfg, creds) - if err != nil { - return nil, err - } if err := client.VerifyProjectExists(cfg.DefaultEnvOrFallback()); err != nil { return nil, err } authStatus = "verified" } - if !created && in.BeforeWrite != nil { - if err := in.BeforeWrite(); err != nil { - return nil, err + publish := func() error { + if in.BeforeWrite != nil { + if err := in.BeforeWrite(); err != nil { + return err + } } + // Back-fill older workspace identities only after both the revision and + // session have been validated, under the same local commit guard. + if created { + if err := ensureManifestProject(projectRoot, cfg.ProjectName); err != nil { + return err + } + } + configJSON, err := EncodeManifestConfig(cfg) + if err != nil { + return err + } + return workspace.InitWorkspaceEnv(projectRoot, workspace.EnvInit{ + Kind: workspace.EnvBackendInfisical, + ConfigJSON: configJSON, + EnvironmentNames: cfg.Environments, + }) } - - configJSON, err := EncodeManifestConfig(cfg) - if err != nil { - return nil, err + if in.SkipVerify { + err = publish() + } else { + err = session.WithUnchanged(current, publish) } - if err := workspace.InitWorkspaceEnv(projectRoot, workspace.EnvInit{ - Kind: workspace.EnvBackendInfisical, - ConfigJSON: configJSON, - EnvironmentNames: cfg.Environments, - }); err != nil { + if err != nil { if created { return nil, bindingWriteError(projectRoot, cfg, err) } @@ -316,8 +318,11 @@ func dedupeStrings(in []string) []string { func bindingWriteError(root string, cfg *WorkspaceConfig, err error) error { code := cliErrors.ONE_CLI_ERROR var original *output.Error - if errors.As(err, &original) && original.Code == string(cliErrors.SERVE_MANIFEST_CONFLICT) { - code = cliErrors.SERVE_MANIFEST_CONFLICT + if errors.As(err, &original) { + switch original.Code { + case string(cliErrors.SERVE_MANIFEST_CONFLICT), string(cliErrors.INFISICAL_AUTH_MISSING), string(cliErrors.INFISICAL_AUTH_FAILED): + code = cliErrors.Code(original.Code) + } } return cliErrors.New(code, i18n.Errorf("infisical.init.binding_write_failed", cfg.ProjectName, cfg.ProjectID, workspace.ManifestPath(root), err).Error()). diff --git a/packages/cli/internal/modules/environment/binding_session_test.go b/packages/cli/internal/modules/environment/binding_session_test.go new file mode 100644 index 00000000..63b906d8 --- /dev/null +++ b/packages/cli/internal/modules/environment/binding_session_test.go @@ -0,0 +1,174 @@ +package environment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" + "github.com/zalando/go-keyring" +) + +func TestWorkspaceBindingRejectsSessionChangesWithoutPublishing(t *testing.T) { + for _, stage := range []string{"create", "metadata", "verification"} { + for _, change := range []string{"unchanged", "logout", "account", "organization", "site", "token", "expiry"} { + t.Run(stage+"/"+change, func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + scope := unboundScope(t) + root := scope.WorkingDirectory() + manifest, err := workspace.ReadManifest(root) + if err != nil { + t.Fatal(err) + } + if stage == "create" { + // An interrupted creation must not back-fill an older manifest's + // identity before rejecting the changed session. + manifest.Workspace = nil + } else { + manifest.Env = &workspace.EnvironmentConfig{ProjectID: "previous", Environments: []string{"dev"}} + } + if err := workspace.WriteManifest(root, manifest); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(workspace.ManifestPath(root)) + if err != nil { + t.Fatal(err) + } + _, revision, err := workspace.ReadManifestSnapshot(root) + if err != nil { + t.Fatal(err) + } + var foreignRequests, creates, verifies atomic.Int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"secrets":[]}`) + })) + defer foreign.Close() + var original session.Session + store := func(current session.Session) error { + raw, err := json.Marshal(current) + if err != nil { + return err + } + return keyring.Set("one-cli.infisical", "session", string(raw)) + } + var changed atomic.Bool + changeSession := func() { + if !changed.CompareAndSwap(false, true) || change == "unchanged" { + return + } + if change == "logout" { + if err := session.Logout(); err != nil { + t.Error(err) + } + return + } + current := original + switch change { + case "account": + current.UserID = "another-user" + case "organization": + current.OrganizationID = "another-org" + case "site": + current.SiteURL = foreign.URL + case "token": + current.Token = "replacement-test-token" + case "expiry": + current.ExpiresAt = time.Now().Add(-time.Minute) + } + if err := store(current); err != nil { + t.Error(err) + } + } + 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 original-test-token" { + t.Error("binding used a different session for remote work") + } + switch r.URL.Path { + case "/api/v2/workspace": + creates.Add(1) + changeSession() + fmt.Fprint(w, `{"project":{"id":"remote","name":"demo"}}`) + case "/api/v1/workspace/remote": + if stage == "metadata" { + changeSession() + } + fmt.Fprint(w, `{"workspace":{"id":"remote","name":"demo","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`) + case "/api/v3/secrets/raw": + verifies.Add(1) + if stage == "verification" { + changeSession() + } + fmt.Fprint(w, `{"secrets":[]}`) + default: + t.Errorf("unexpected binding request: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer upstream.Close() + original = session.Session{ + Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, + Token: "original-test-token", + } + if err := store(original); err != nil { + t.Fatal(err) + } + input := BindWorkspaceInput{Scope: scope, Create: stage == "create", Revision: revision} + if stage != "create" { + input.ProjectID = "remote" + } + service := newTestService(t) + result, err := service.BindWorkspace(context.Background(), input) + if foreignRequests.Load() != 0 || (stage != "create" && verifies.Load() != 1) { + t.Fatal("verification did not stay on the original session and site") + } + if change == "unchanged" { + if err != nil || result.ProjectID != "remote" { + t.Fatalf("unchanged session failed to bind: %+v %v", result, err) + } + return + } + wantCode := "INFISICAL_AUTH_FAILED" + if change == "logout" { + wantCode = "INFISICAL_AUTH_MISSING" + } + var coded *output.Error + if !errors.As(err, &coded) || coded.Code != wantCode { + t.Fatalf("session change lost its authentication error: %v", err) + } + if strings.Contains(err.Error(), original.Token) || strings.Contains(err.Error(), "replacement-test-token") { + t.Fatal("session failure exposed a token") + } + after, readErr := os.ReadFile(workspace.ManifestPath(root)) + if readErr != nil || string(before) != string(after) { + t.Fatalf("session change published local changes: %v", readErr) + } + if stage == "create" { + if coded.Context["project_id"] != "remote" || coded.Context["partial_state"] != "project_created_binding_unsaved" { + t.Fatalf("lost the created project for recovery: %+v", coded.Context) + } + if err := store(original); err != nil { + t.Fatal(err) + } + result, err = service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, ProjectID: "remote", Revision: revision}) + if err != nil || result.ProjectID != "remote" || creates.Load() != 1 { + t.Fatalf("recovery failed or created a duplicate project: %+v %v", result, err) + } + } + }) + } + } +} diff --git a/packages/cli/internal/modules/environment/binding_test.go b/packages/cli/internal/modules/environment/binding_test.go index b8576959..1ece4249 100644 --- a/packages/cli/internal/modules/environment/binding_test.go +++ b/packages/cli/internal/modules/environment/binding_test.go @@ -281,6 +281,7 @@ func TestSameNamedWorkspacesCreateDistinctProjectsAndRetryUsesBinding(t *testing } func TestBindingInitializationIsSerializedAndMalformedConfigIsRejected(t *testing.T) { + mockSession(t, "http://127.0.0.1:1") service := newTestService(t) scope := unboundScope(t) calls := 0 diff --git a/packages/cli/internal/modules/environment/target.go b/packages/cli/internal/modules/environment/target.go index 64664aac..44b3dc77 100644 --- a/packages/cli/internal/modules/environment/target.go +++ b/packages/cli/internal/modules/environment/target.go @@ -83,9 +83,13 @@ func (s *Service) BindWorkspace(ctx context.Context, input BindWorkspaceInput) ( if projectID == "" && !input.Create { return nil, i18n.Errorf("env.bind.selection_required") } - initInput := infisical.InitInput{ProjectID: projectID, BeforeWrite: checkRevision} + current, err := session.Require() + if err != nil { + return nil, err + } + initInput := infisical.InitInput{ProjectID: projectID, Session: current, BeforeWrite: checkRevision} if projectID != "" { - project, err := infisical.Project(ctx, projectID) + project, err := infisical.ProjectWithSession(ctx, current, projectID) if err != nil { return nil, err } diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 80e924d9..66e573df 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -246,8 +246,9 @@ "env.flag.path": "Shared credential folder (current level only, not recursive)", "env.flag.environment_name": "Environment name", "env.bind.short": "Create or bind an Infisical project", - "env.bind.flag.create": "Create and bind the workspace Infisical project; preserve an existing binding", + "env.bind.flag.create": "Workspace only: create and bind the Infisical project; preserve an existing binding", "env.bind.create_conflict": "--create cannot be combined with --project-id.", + "env.bind.create_workspace_only": "--create is only available for workspace binding. Initialize shared credentials with one env bind --global.", "env.bind.selection_required": "Run one env bind to select a project interactively; use --create or --project-id in non-interactive mode.", "env.bind.workspace_env_fixed": "The workspace default environment is fixed at dev; --env on bind is only for --global shared credentials.", "env.bind.create_option": "Create and bind an Infisical project for this workspace", @@ -255,7 +256,7 @@ "env.bind.progress": "Binding the Infisical project…", "env.bind.workspace_success": "✓ Bound Infisical project: %s (%s). Binding saved to %s.", "env.bind.success": "✓ Bound shared credential project: %s (%s), default environment: %s.", - "env.bind.tip": "Sign in with one login.\n\nRun one env bind to select an existing project or create a workspace project interactively. Use --create to explicitly create and bind storage, or --project-id to bind an existing Secret Manager project. Existing bindings are preserved. Workspace bindings are saved to one.manifest.toml, with default environment dev. Without a binding, set fails instead of creating a project.\n\nRun one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration.", + "env.bind.tip": "Sign in with one login.\n\nRun one env bind to select an existing project or create a workspace project interactively. Use --create to explicitly create and bind storage, or --project-id to bind an existing Secret Manager project. Existing bindings are preserved. Workspace bindings are saved to one.manifest.toml, with default environment dev. Without a binding, set fails instead of creating a project.\n\nRun one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration. --create is only available for workspace binding and cannot be combined with --global.", "env.flag.project_id": "Existing Infisical project ID", "env.flag.default_environment": "Default environment", "env.bind.global_required": "Use one env bind --global.", @@ -420,6 +421,7 @@ "auth.login_required": "Not signed in to Infisical. Run one login.", "auth.store_read_failed": "Cannot read the system credential store. Unlock it and retry: %w", "auth.session_expired": "Your Infisical session expired. Run one login again.", + "auth.session_changed": "Your Infisical login has changed. Confirm your account and bind again.", "auth.store_write_failed": "Cannot save sign-in. Enable and unlock the system credential store (Secret Service on Linux): %w", "auth.site_root_required": "The Infisical address must be an instance root URL.", "auth.https_required": "Infisical requires HTTPS, except for local instances.", @@ -641,7 +643,7 @@ "creation.git_root_mismatch": "%s is not the Git repository root; the root is %s. Create the workspace at the target repository root.", "creation.git_not_empty": "Cannot create a workspace in %s: Git still tracks files or uncommitted changes. Check git status or choose an empty repository.", "creation.shared_hooks_skipped": "The workspace was created. This directory shares hooks with other Git worktrees, so automatic installation was skipped. Run one init hooks if needed.", - "infisical.init.binding_write_failed": "Infisical project %s (%s) was created, but its binding could not be saved to %s: %w. Keep the project ID and connect that project after fixing the file write error to avoid creating a duplicate.", + "infisical.init.binding_write_failed": "Infisical project %s (%s) was created, but its binding could not be saved to %s: %w. Keep the project ID and connect that project after resolving the issue above to avoid creating a duplicate.", "env.initializing": "Setting up environment variable storage and saving the variable", "env.saving": "Saving the environment variable", "env.project_renamed": "Project name %s was taken; using %s.", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 09d19ed1..a31ff134 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -246,8 +246,9 @@ "env.flag.path": "共享凭据目录(仅当前层,不递归)", "env.flag.environment_name": "环境名", "env.bind.short": "创建或绑定 Infisical 项目", - "env.bind.flag.create": "创建并绑定工作区的 Infisical 项目;已有绑定会保留", + "env.bind.flag.create": "仅用于工作区:创建并绑定 Infisical 项目;已有绑定会保留", "env.bind.create_conflict": "--create 不能与 --project-id 同时使用。", + "env.bind.create_workspace_only": "--create 仅用于工作区绑定。初始化共享凭据请使用 one env bind --global。", "env.bind.selection_required": "请运行 one env bind 交互选择项目;非交互模式请使用 --create 或 --project-id。", "env.bind.workspace_env_fixed": "工作区默认环境固定为 dev;绑定命令的 --env 仅用于 --global 共享凭据。", "env.bind.create_option": "创建并绑定当前工作区的 Infisical 项目", @@ -255,7 +256,7 @@ "env.bind.progress": "正在绑定 Infisical 项目…", "env.bind.workspace_success": "✓ 已绑定 Infisical 项目:%s(%s)。绑定已保存到 %s。", "env.bind.success": "✓ 已绑定共享凭据项目:%s(%s),默认环境:%s。", - "env.bind.tip": "先通过 one login 登录。\n\n运行 one env bind 交互选择已有项目或创建工作区项目。使用 --create 显式创建并绑定,或使用 --project-id 绑定已有 Secret Manager 项目。已有绑定会保留,工作区绑定写入 one.manifest.toml,默认环境固定为 dev。未绑定时 set 会报错,不会自动创建项目。\n\n运行 one env bind --global 初始化共享凭据位置。未绑定时自动创建或复用 shared-credentials 项目,默认环境为 dev,无需选择项目或填写 ID。已有绑定会保留;--env 可修改默认环境。共享凭据绑定信息保存在本机配置中。", + "env.bind.tip": "先通过 one login 登录。\n\n运行 one env bind 交互选择已有项目或创建工作区项目。使用 --create 显式创建并绑定,或使用 --project-id 绑定已有 Secret Manager 项目。已有绑定会保留,工作区绑定写入 one.manifest.toml,默认环境固定为 dev。未绑定时 set 会报错,不会自动创建项目。\n\n运行 one env bind --global 初始化共享凭据位置。未绑定时自动创建或复用 shared-credentials 项目,默认环境为 dev,无需选择项目或填写 ID。已有绑定会保留;--env 可修改默认环境。共享凭据绑定信息保存在本机配置中。 --create 仅用于工作区绑定,不能与 --global 同时使用。", "env.flag.project_id": "已有 Infisical 项目 ID", "env.flag.default_environment": "默认环境", "env.bind.global_required": "请使用 one env bind --global", @@ -420,6 +421,7 @@ "auth.login_required": "尚未登录 Infisical,请运行 one login。", "auth.store_read_failed": "无法读取系统凭据存储,请解锁后重试:%w", "auth.session_expired": "Infisical 登录已过期,请重新运行 one login。", + "auth.session_changed": "Infisical 登录状态已改变,请确认账号后重新绑定。", "auth.store_write_failed": "无法保存登录:请启用并解锁系统凭据存储(Linux 需要 Secret Service):%w", "auth.site_root_required": "Infisical 地址必须是实例根地址", "auth.https_required": "Infisical 地址必须使用 HTTPS(本机实例除外)", @@ -641,7 +643,7 @@ "creation.git_root_mismatch": "%s 不是 Git 仓库根目录,仓库根目录是 %s。请在目标仓库根目录创建工作区。", "creation.git_not_empty": "无法在 %s 中创建工作区:Git 仍记录着文件或未提交的修改。请先检查 git status,或选择一个空仓库。", "creation.shared_hooks_skipped": "工作区已创建。此目录与其他 Git worktree 共享 hooks,已跳过自动安装;需要时可运行 one init hooks。", - "infisical.init.binding_write_failed": "Infisical 项目 %s(%s)已创建,但无法将绑定保存到 %s:%w。请保留项目 ID,修复文件写入问题后连接该项目,避免重复创建。", + "infisical.init.binding_write_failed": "Infisical 项目 %s(%s)已创建,但无法将绑定保存到 %s:%w。请保留项目 ID,解决上述问题后连接该项目,避免重复创建。", "env.initializing": "正在创建环境变量存储并保存变量", "env.saving": "正在保存环境变量", "env.project_renamed": "项目名称 %s 已被占用,已改用 %s。", diff --git a/packages/cli/internal/platform/infisicalsession/commit_test.go b/packages/cli/internal/platform/infisicalsession/commit_test.go new file mode 100644 index 00000000..56547019 --- /dev/null +++ b/packages/cli/internal/platform/infisicalsession/commit_test.go @@ -0,0 +1,90 @@ +package infisicalsession + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" +) + +func TestWithUnchangedSerializesCommitWithLogout(t *testing.T) { + isolate(t) + original := &Session{Info: Info{UserID: "user", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"} + if err := save(original); err != nil { + t.Fatal(err) + } + bindingPath := filepath.Join(t.TempDir(), "binding.json") + entered, release := make(chan struct{}), make(chan struct{}) + commitDone := make(chan error, 1) + go func() { + commitDone <- WithUnchanged(original, func() error { + close(entered) + <-release + return os.WriteFile(bindingPath, []byte(`{"projectId":"remote"}`), 0600) + }) + }() + <-entered + logoutStarted, logoutDone := make(chan struct{}), make(chan error, 1) + go func() { + close(logoutStarted) + logoutDone <- Logout() + }() + <-logoutStarted + var logoutErr error + loggedOutEarly := false + select { + case logoutErr = <-logoutDone: + loggedOutEarly = true + case <-time.After(25 * time.Millisecond): + } + close(release) + commitErr := <-commitDone + if !loggedOutEarly { + logoutErr = <-logoutDone + } + if loggedOutEarly || commitErr != nil || logoutErr != nil { + t.Fatalf("logout overlapped the local commit: early=%v commit=%v logout=%v", loggedOutEarly, commitErr, logoutErr) + } + if _, err := Require(); err == nil { + t.Fatal("logout did not complete after the commit") + } +} + +func TestWithUnchangedLocalizesSessionChangeAndPreservesWriteErrors(t *testing.T) { + t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) + for _, test := range []struct{ locale, message string }{ + {"zh-CN", "登录状态已改变"}, + {"en-US", "login has changed"}, + } { + t.Run(test.locale, func(t *testing.T) { + isolate(t) + if err := i18n.Init(test.locale); err != nil { + t.Fatal(err) + } + original := &Session{Info: Info{UserID: "user", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"} + if err := save(original); err != nil { + t.Fatal(err) + } + writeErr := errors.New("disk unavailable") + if err := WithUnchanged(original, func() error { return writeErr }); !errors.Is(err, writeErr) { + t.Fatalf("lost local write error: %v", err) + } + changed := *original + changed.UserID = "another-user" + if err := save(&changed); err != nil { + t.Fatal(err) + } + called := false + err := WithUnchanged(original, func() error { called = true; return nil }) + var coded *output.Error + if called || !errors.As(err, &coded) || coded.Code != "INFISICAL_AUTH_FAILED" || !strings.Contains(err.Error(), test.message) { + t.Fatalf("session change was not rejected in %s: %v", test.locale, err) + } + }) + } +} diff --git a/packages/cli/internal/platform/infisicalsession/session.go b/packages/cli/internal/platform/infisicalsession/session.go index 5f2036e1..fa77c389 100644 --- a/packages/cli/internal/platform/infisicalsession/session.go +++ b/packages/cli/internal/platform/infisicalsession/session.go @@ -111,6 +111,23 @@ func sessionLock(fn func() error) error { defer lock.Unlock() return fn() } + +// WithUnchanged serializes a local commit with login and logout. Remote work +// must finish before calling it; fn must not acquire the session lock again. +func WithUnchanged(expected *Session, fn func() error) error { + return sessionLock(func() error { + current, err := Require() + if err != nil { + return err + } + if expected == nil || current.SiteURL != expected.SiteURL || current.UserID != expected.UserID || + current.OrganizationID != expected.OrganizationID || current.Token != expected.Token { + return cliErrors.New(cliErrors.INFISICAL_AUTH_FAILED, i18n.T("auth.session_changed")) + } + return fn() + }) +} + func Logout() error { return sessionLock(func() error { path, e := ConfigPath("login-generation") diff --git a/packages/cli/internal/transport/cobra/env/global.go b/packages/cli/internal/transport/cobra/env/global.go index 8c613dea..3b2ccbde 100644 --- a/packages/cli/internal/transport/cobra/env/global.go +++ b/packages/cli/internal/transport/cobra/env/global.go @@ -40,6 +40,9 @@ func configureGlobal(parent *cobra.Command, deps Dependencies) { if create && id != "" { return i18n.Errorf("env.bind.create_conflict") } + if global && create { + return i18n.Errorf("env.bind.create_workspace_only") + } if !global { if c.Flags().Changed("path") { return i18n.Errorf("env.path_global_required") diff --git a/packages/cli/internal/transport/cobra/env/global_test.go b/packages/cli/internal/transport/cobra/env/global_test.go index 6cd5fa9b..8ffaddd7 100644 --- a/packages/cli/internal/transport/cobra/env/global_test.go +++ b/packages/cli/internal/transport/cobra/env/global_test.go @@ -7,7 +7,10 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -34,6 +37,7 @@ func TestGlobalBindCreatesReusesAndPreservesStorage(t *testing.T) { {name: "create default", wantProject: "shared", wantEnv: "dev", wantCreates: 1}, {name: "reuse default", defaultExists: true, wantProject: "shared", wantEnv: "dev"}, {name: "preserve custom binding", savedCustom: true, wantProject: "custom", wantEnv: "prod"}, + {name: "explicit false create preserves binding", args: []string{"--create=false"}, savedCustom: true, wantProject: "custom", wantEnv: "prod"}, {name: "create with requested environment", args: []string{"--env", "prod"}, wantProject: "shared", wantEnv: "prod", wantCreates: 1}, {name: "reuse with requested environment", defaultExists: true, args: []string{"--env", "prod"}, wantProject: "shared", wantEnv: "prod"}, {name: "change saved environment", savedCustom: true, args: []string{"--env", "dev"}, wantProject: "custom", wantEnv: "dev"}, @@ -144,6 +148,60 @@ func TestGlobalBindCreatesReusesAndPreservesStorage(t *testing.T) { } } +func TestGlobalBindRejectsCreateBeforeRemoteAccessInBothLanguages(t *testing.T) { + t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) + for _, locale := range []string{"zh-CN", "en-US"} { + for _, loggedIn := range []bool{false, true} { + t.Run(locale+"/"+map[bool]string{false: "logged out", true: "logged in"}[loggedIn], func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := i18n.Init(locale); err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected remote access", http.StatusInternalServerError) + })) + defer upstream.Close() + if loggedIn { + raw, err := 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 != nil { + t.Fatal(err) + } + if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { + t.Fatal(err) + } + } + path, err := session.ConfigPath("global-env.json") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + before := []byte(`{"siteUrl":"saved-site","projectId":"saved-project","defaultEnvironment":"prod"}`) + if err := os.WriteFile(path, before, 0600); err != nil { + t.Fatal(err) + } + cmd := Commands(Dependencies{})[0] + cmd.SilenceErrors, cmd.SilenceUsage = true, true + cmd.SetArgs([]string{"bind", "--global", "--create"}) + err = cmd.Execute() + if err == nil || err.Error() != i18n.T("env.bind.create_workspace_only") || !strings.Contains(err.Error(), "one env bind --global") { + t.Fatalf("invalid flags did not return the localized recovery command: %v", err) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) || requests.Load() != 0 { + t.Fatalf("invalid flags contacted Infisical or changed the binding: %v", err) + } + }) + } + } +} + func TestGlobalBindRequiresLogin(t *testing.T) { keyring.MockInit() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) diff --git a/packages/cli/testdata/reference/help/env_bind.txt b/packages/cli/testdata/reference/help/env_bind.txt index 11a81df5..8bfb3369 100644 --- a/packages/cli/testdata/reference/help/env_bind.txt +++ b/packages/cli/testdata/reference/help/env_bind.txt @@ -16,10 +16,10 @@ Sign in with one login. Run one env bind to select an existing project or create a workspace project interactively. Use --create to explicitly create and bind storage, or --project-id to bind an existing Secret Manager project. Existing bindings are preserved. Workspace bindings are saved to one.manifest.toml, with default environment dev. Without a binding, set fails instead of creating a project. -Run one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration. +Run one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration. --create is only available for workspace binding and cannot be combined with --global. COMMON OPTIONS - --create Create and bind the workspace Infisical project; preserve an existing binding + --create Workspace only: create and bind the Infisical project; preserve an existing binding --env Default environment --global Manage Infisical shared credentials, including outside a workspace -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped)