Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Without `-p`, the root dev task must exist. With `-p`, the selected project's de

Named tasks share one preparation policy. Node dependencies are prepared at the workspace root. With pnpm 10.14 or later, One verifies installed dependencies and reuses matching installations, including manual installs. When needed, it runs `pnpm install --no-frozen-lockfile`. Go preparation resolves the fixed module build list without automatically running `go mod tidy` or `go work sync`. Preparation failure prevents task startup; `one exec` does not install dependencies.

One assigns a task to the registered project containing its effective working directory. Enabled projects receive their own Infisical snapshot for the invocation. Parallel projects do not share variable maps, and child processes inherit the correct project environment. No env plugin declaration or secret value is required in your mise file.
One assigns a task to the registered project containing its effective working directory. When projects enable environment variables, One makes a single recursive request to Infisical at startup for the selected environment, then distributes variables in memory by project directory. Root and ancestor variables are shared; project variables override matching ancestor keys. Variables from other projects or deeper subdirectories are excluded. Parallel projects receive separate variable maps, and child processes inherit the correct project environment. The next invocation fetches fresh values. No env plugin declaration or secret value is required in your mise file.

## Terminal and exit

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/docs/en/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ POSIX shell argument forwarding preserves spaces, quotes, Unicode and metacharac

Task ownership follows the deepest registered project directory containing its effective working directory. Root aggregates do not receive a union of child variables.

One batch-loads an immutable project snapshot once per invocation. `--env` selects a declared environment, defaulting to `dev`. Values override matching shell, mise and task variables. Each leaf receives only its project's snapshot, including empty values. Child processes inherit the same environment.
When projects enable environment variables, One makes a single recursive request to Infisical per invocation and builds immutable project snapshots in memory. A later invocation fetches fresh values. `--env` selects a declared environment, defaulting to `dev`. Each project inherits root, ancestor, and project-directory variables in that order, with closer folders overriding matching keys. Variables from other projects or deeper subdirectories are excluded. Values override matching shell, mise and task variables. Each leaf receives only its project's snapshot, including empty values. Child processes inherit the same environment.

Infisical recursive reads support at most 20 directory levels. Projects beyond that depth fail before the request. A response containing a secret without an absolute folder path also fails, preventing incomplete or incorrectly scoped variables from being distributed.

Environment values and commands are sent to private leaf processes through an authenticated loopback channel. Generated YAML contains graph metadata and worker identities; it contains no injected variable values. No environment plugins or temporary binding TOML are generated. The channel and private configuration directory are closed and removed when the invocation ends.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/zh/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ one run dev -p web -- --port 4300

所有命名任务共用准备策略。Node 依赖在工作区根目录准备;pnpm 10.14 及以上会检查现有安装并复用匹配的依赖,包括手动安装的依赖。需要安装时执行 `pnpm install --no-frozen-lockfile`。Go 准备会解析固定模块构建列表,不自动执行 `go mod tidy` 或 `go work sync`。准备失败则不启动任务;`one exec` 不安装依赖。

One 按任务实际工作目录匹配登记项目。启用环境变量的项目各自获取本次运行的 Infisical 快照;并行项目不共用变量表,孙进程继承对应项目的环境。无需在 mise 中声明 env 插件或填写密钥值。
One 按任务实际工作目录匹配登记项目。项目启用环境变量时,启动只向 Infisical 发起一次递归读取,获取所选环境的变量,再在内存中按项目目录分发:根目录与父目录的变量共享,项目目录的同名变量覆盖父目录,其他项目和更深层子目录的变量不会混入。并行项目各自使用独立变量表,孙进程继承对应项目的环境;下次启动重新获取最新值。无需在 mise 中声明 env 插件或填写密钥值。

## 终端与退出

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/docs/zh/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ POSIX shell 参数保留空格、引号、中文和元字符。在 Windows 上

任务归属按实际工作目录匹配最深的已注册项目。根聚合任务不合并各子项目变量。

每次运行批量读取一次不可变的项目变量快照,`--env` 选择已声明环境,默认 `dev`。变量覆盖同名 shell、mise 和任务变量;每个叶子只接收自己的项目快照,包含空值。后续子进程继承相同环境。
项目启用环境变量时,每次运行只向 Infisical 发起一次递归读取,在内存中生成各项目的不可变变量快照;再次运行重新读取最新值。`--env` 选择已声明环境,默认 `dev`。每个项目依次继承根目录、父目录和自身目录的变量,较近目录覆盖同名键,其他项目和更深层子目录的变量不会混入。变量覆盖同名 shell、mise 和任务变量;每个叶子只接收自己的项目快照,包含空值。后续子进程继承相同环境。

Infisical 递归读取最多支持 20 层目录。超过此深度的项目会在请求前报错;响应中的变量缺少绝对目录路径时也会报错,避免分发不完整或归属不明的变量。

变量和命令通过本地认证通道交给私有叶子进程。生成的 YAML 只含任务图元数据和 worker 标识,不含注入变量值;不生成环境插件或临时绑定 TOML。运行结束后关闭通道,清理私有配置目录。

Expand Down
111 changes: 39 additions & 72 deletions packages/cli/internal/adapters/env/infisical/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package infisical
import (
"context"
"path/filepath"
"sync"
"strings"
"time"

"github.com/go-resty/resty/v2"
Expand All @@ -12,6 +12,7 @@ import (
"github.com/infisical/go-sdk/packages/util"

"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"
)

Expand Down Expand Up @@ -39,8 +40,12 @@ func FetchSecretsForSubproject(ctx context.Context, projectRoot, relativeDir, en
return projects[relativeDir], nil
}

// Infisical's recursive list endpoint supports at most 20 directory levels.
const maxSnapshotDepth = 20

// Snapshot lifetime is exactly one call: a later launch always reads fresh
// values. Unique folders are requested concurrently, then merged in chain order.
// values. One recursive read supplies all projects, then each project receives
// only the folders in its inheritance chain, merged from root to leaf.
func fetchSecretsForProjects(ctx context.Context, projectRoot string, dirs []string, envName string) (map[string]map[string]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
Expand All @@ -66,29 +71,42 @@ func fetchSecretsForProjects(ctx context.Context, projectRoot string, dirs []str
}
cfg.SiteURL = siteURL
chains := make(map[string][]string, len(dirs))
indices := map[string]int{}
paths := []string{}
paths := map[string]bool{}
for _, dir := range dirs {
resolution, err := resolveRunPath(projectRoot, dir)
if err != nil {
return nil, err
}
if len(resolution.Chain)-1 > maxSnapshotDepth {
return nil, cliErrors.New(cliErrors.INFISICAL_API_ERROR,
i18n.Tf("infisical.snapshot_depth_exceeded", dir, maxSnapshotDepth))
}
chains[dir] = resolution.Chain
for _, path := range resolution.Chain {
if _, exists := indices[path]; !exists {
indices[path] = len(paths)
paths = append(paths, path)
}
paths[path] = true
}
}
folders, err := fetchFolders(ctx, cfg, creds, env, paths)
snapshot, err := fetchSnapshot(ctx, cfg, creds, env)
if err != nil {
return nil, err
}
folders := make(map[string][]models.Secret, len(paths))
for _, secret := range snapshot {
// A recursive response must identify each secret's folder. Treating a
// missing path as root would distribute project-only values to siblings.
if !strings.HasPrefix(secret.SecretPath, "/") {
return nil, cliErrors.New(cliErrors.INFISICAL_API_ERROR,
i18n.T("infisical.snapshot_path_missing"))
}
path := NormalizePath(secret.SecretPath)
if paths[path] {
folders[path] = append(folders[path], secret)
}
}
for dir, chain := range chains {
merged := map[string]string{}
for _, path := range chain {
for _, secret := range folders[indices[path]] {
for _, secret := range folders[path] {
merged[secret.SecretKey] = secret.SecretValue
}
}
Expand All @@ -97,14 +115,10 @@ func fetchSecretsForProjects(ctx context.Context, projectRoot string, dirs []str
return result, nil
}

const maxFolderRequests = 6

func fetchFolders(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials, env string, paths []string) ([][]models.Secret, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
func fetchSnapshot(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials, env string) ([]models.Secret, error) {
// The SDK's high-level client does not pass its constructor context to
// HTTP requests. Reuse its list API and error contract with a cancellable
// request client so Ctrl-C and a failed sibling stop in-flight reads.
// request client so Ctrl-C stops the snapshot read.
client := resty.New().
SetBaseURL(util.AppendAPIEndpoint(cfg.SiteURLOrDefault())).
SetHeader("User-Agent", "one-cli/"+clientVersion).
Expand All @@ -116,64 +130,17 @@ func fetchFolders(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials,
return ctx.Err()
})
defer client.GetClient().CloseIdleConnections()
results := make([][]models.Secret, len(paths))
jobs := make(chan int, len(paths))
for i := range paths {
jobs <- i
}
close(jobs)
var wg sync.WaitGroup
var once sync.Once
var firstErr error
for range min(maxFolderRequests, len(paths)) {
wg.Go(func() {
for i := range jobs {
if ctx.Err() != nil {
return
}
response, err := api.CallListSecretsV3(nil, client, api.ListSecretsV3RawRequest{
ProjectID: cfg.ProjectID, Environment: env, SecretPath: paths[i],
ExpandSecretReferences: true,
})
if err != nil {
if ctx.Err() != nil {
return
}
err = mapAPIError(err)
// Missing ancestors are empty, never a reason to hide auth,
// network, or project-not-found failures.
if isFolderNotFound(err) {
continue
}
once.Do(func() { firstErr = err; cancel() })
return
}
results[i] = response.Secrets
}
})
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
if err := ctx.Err(); err != nil {
return nil, err
response, err := api.CallListSecretsV3(nil, client, api.ListSecretsV3RawRequest{
ProjectID: cfg.ProjectID, Environment: env, SecretPath: "/",
ExpandSecretReferences: true, Recursive: true,
})
if ctx.Err() != nil {
return nil, ctx.Err()
}
return results, nil
}

// isFolderNotFound reports whether err is the structured
// INFISICAL_FOLDER_NOT_FOUND envelope (the only "soft" error class in
// the chain walk).
func isFolderNotFound(err error) bool {
if err == nil {
return false
}
type coded interface{ ErrorCode() string }
if c, ok := err.(coded); ok {
return c.ErrorCode() == "INFISICAL_FOLDER_NOT_FOUND"
if err != nil {
return nil, mapAPIError(err)
}
return false
return response.Secrets, nil
}

// resolveRunPath derives the fixed folder inheritance chain for a task directory.
Expand Down
Loading
Loading