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
14 changes: 14 additions & 0 deletions cmd/env-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ func run() error {
return config.RunSetup(args[1:], printUsage)
}

if len(args) > 0 && args[0] == "validate" {
// --lang フラグと AppConfig の language フィールドも言語解決に含める。
// PrescanLang はフラグ解析前に --lang/--language の値だけを先読みする。
prescannedLang := config.PrescanLang(args[1:])
// config 読み込み失敗時は configLang を "" として継続する。
// エラーは後続の本処理(validate ロジック内の LoadAppConfig 呼び出し等)で顕在化する。
var configLang string
if appCfg, err := config.LoadAppConfig(); err == nil {
configLang = appCfg.Language
}
i18n.SetLang(string(i18n.Resolve(prescannedLang, os.Getenv("ENV_SYNC_LANG"), configLang)))
return runValidate(args[1:], printUsage)
}

printVersion := func() {
v, c, d := versionInfo()
fmt.Printf("env-sync version %s (commit: %s, built: %s)\n", v, c, d)
Expand Down
39 changes: 39 additions & 0 deletions cmd/env-sync/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// validate.go は validate サブコマンドの実装を提供する。
// 読み取り専用(GET のみ)で認証・ターゲット設定・API 到達確認を行い、書き込みは行わない。
package main

import (
"fmt"
"os"

"github.com/ptyhard/env-sync/internal/config"
"github.com/ptyhard/env-sync/internal/i18n"
"github.com/ptyhard/env-sync/internal/provider"
)

// runValidate は validate サブコマンドを実行する。
// --provider で指定した provider(デフォルト: vercel)の Validator を呼び出して
// 認証トークン・ターゲット設定・API 到達確認を行う。
// def / env ファイルは使用しないため、不在でも fatal にはならない。
func runValidate(args []string, printUsage func()) error {
printVersion := func() {
v, c, d := versionInfo()
fmt.Printf("env-sync version %s (commit: %s, built: %s)\n", v, c, d)
}
opts := config.ParseFlags(args, printUsage, printVersion)

pname := opts.Provider
p, ok := provider.LookupProvider(pname)
if !ok {
fmt.Fprint(os.Stderr, i18n.T(i18n.MsgValidateProviderUnsupported, pname))
return nil
}

v, ok := p.(provider.Validator)
if !ok {
fmt.Fprint(os.Stderr, i18n.T(i18n.MsgValidateProviderUnsupported, pname))
return nil
}

return v.Validate(opts, nil)
}
80 changes: 80 additions & 0 deletions cmd/env-sync/validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"os"
"os/exec"
"strings"
"testing"
)

// TestValidateSubcommand_HelpExitsZero は validate --help が exit 0 で終了することを確認する。
func TestValidateSubcommand_HelpExitsZero(t *testing.T) {
bin := t.TempDir() + "/env-sync-test"
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("ビルド失敗: %s\n%s", err, out)
}
cmd := exec.Command(bin, "validate", "--help")
if err := cmd.Run(); err != nil {
t.Errorf("validate --help は exit 0 であるべき: %s", err)
}
}

// TestValidateSubcommand_NoDefOrEnv は def/env ファイルが存在しなくても fatal にならないことを確認する。
// token / project_id が未設定のため exit 1 になるが、ファイル不在でパニックしないことを確認する。
func TestValidateSubcommand_NoDefOrEnv(t *testing.T) {
bin := t.TempDir() + "/env-sync-test"
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("ビルド失敗: %s\n%s", err, out)
}

dir := t.TempDir()
cmd := exec.Command(bin, "validate",
"--env", dir+"/nonexistent.env",
"--def", dir+"/nonexistent.yaml",
)
// VERCEL_PROJECT_ID を未設定にして API 呼び出しを回避
env := []string{
"VERCEL_TOKEN=",
"VERCEL_PROJECT_ID=",
"GITHUB_TOKEN=",
"GITHUB_REPO=",
"XDG_CONFIG_HOME=" + dir + "/no-global",
}
cmd.Env = append(os.Environ(), env...)
out, err := cmd.CombinedOutput()
output := string(out)

// ファイル不在のエラー(fatal)ではなく、警告を出して継続することを確認
// def/env の不在警告が出ることを確認
if strings.Contains(output, "panic") {
t.Errorf("panic が発生した: %s", output)
}
// def/env 不在の警告または validate ヘッダが出ることを確認
// (exit 1 は許容: token 未設定のため)
_ = err // exit 1 は許容
}

// TestValidateSubcommand_InUsage は --help に validate の説明が含まれることを確認する。
func TestValidateSubcommand_InUsage(t *testing.T) {
bin := t.TempDir() + "/env-sync-test"
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("ビルド失敗: %s\n%s", err, out)
}
out, err := exec.Command(bin, "--help").CombinedOutput()
if err != nil {
t.Fatalf("--help 実行失敗: %s", err)
}
if !strings.Contains(string(out), "validate") {
t.Errorf("--help の出力に validate が含まれない: %s", out)
}
}

// TestRunValidate_ProviderNotValidator_ReturnsNil は Validator 未実装の provider(gcp)を
// 指定した場合に runValidate が nil を返す(型アサーション失敗の分岐)ことを確認する。
// 未登録名は ParseFlags 側で os.Exit(1) になるため、ここでは登録済みだが Validator 未実装の gcp を使う。
func TestRunValidate_ProviderNotValidator_ReturnsNil(t *testing.T) {
args := []string{"--provider", "gcp"}
if err := runValidate(args, func() {}); err != nil {
t.Errorf("Validator 未実装 provider 指定時は nil を期待: %v", err)
}
}
144 changes: 123 additions & 21 deletions internal/config/appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,23 @@ type GitHubRepoConf struct {
// VercelTarget は ResolveVercelTargets が返す解決済みターゲット。
type VercelTarget struct {
// Name は config 上のターゲット名。単一解決の場合は空になる。
Name string
ProjectID string
TeamID string
Token string
Name string
ProjectID string
TeamID string
Token string
TokenSource string // 取得元: "env" / "config" / ""(未設定)
ProjectIDSource string // 取得元: "env" / "config" / "project_json" / ""(未設定)
TeamIDSource string // 取得元: "env" / "config" / "project_json" / ""(未設定)
}

// GitHubTarget は ResolveGitHubTargets が返す解決済みターゲット。
type GitHubTarget struct {
// Name は config 上のターゲット名。単一解決の場合は空になる。
Name string
Repo string
Token string
Name string
Repo string
Token string
TokenSource string // 取得元: "env" / "config" / ""(未設定)
RepoSource string // 取得元: "env" / "config" / "git_remote" / ""(未設定)
}

// AppVercelConfig は Vercel の認証情報・ID。
Expand Down Expand Up @@ -325,6 +330,61 @@ func (cfg *AppConfig) ResolveGitHubRepo() string {
return cfg.GitHub.Repo
}

// ResolveVercelTokenWithSource は token とその取得元 ("env"/"config"/"") を返す。
func (cfg *AppConfig) ResolveVercelTokenWithSource() (val, source string) {
if v := os.Getenv("VERCEL_TOKEN"); v != "" {
return v, "env"
}
if cfg.Vercel.Token != "" {
return cfg.Vercel.Token, "config"
}
return "", ""
}

// ResolveVercelProjectIDWithSource は project ID とその取得元 ("env"/"config"/"") を返す。
func (cfg *AppConfig) ResolveVercelProjectIDWithSource() (val, source string) {
if v := os.Getenv("VERCEL_PROJECT_ID"); v != "" {
return v, "env"
}
if cfg.Vercel.ProjectID != "" {
return cfg.Vercel.ProjectID, "config"
}
return "", ""
}

// ResolveVercelTeamIDWithSource は team ID とその取得元 ("env"/"config"/"") を返す。
func (cfg *AppConfig) ResolveVercelTeamIDWithSource() (val, source string) {
if v := os.Getenv("VERCEL_TEAM_ID"); v != "" {
return v, "env"
}
if cfg.Vercel.TeamID != "" {
return cfg.Vercel.TeamID, "config"
}
return "", ""
}

// ResolveGitHubTokenWithSource は token とその取得元 ("env"/"config"/"") を返す。
func (cfg *AppConfig) ResolveGitHubTokenWithSource() (val, source string) {
if v := os.Getenv("GITHUB_TOKEN"); v != "" {
return v, "env"
}
if cfg.GitHub.Token != "" {
return cfg.GitHub.Token, "config"
}
return "", ""
}

// ResolveGitHubRepoWithSource は repo とその取得元 ("env"/"config"/"") を返す。
func (cfg *AppConfig) ResolveGitHubRepoWithSource() (val, source string) {
if v := os.Getenv("GITHUB_REPO"); v != "" {
return v, "env"
}
if cfg.GitHub.Repo != "" {
return cfg.GitHub.Repo, "config"
}
return "", ""
}

// resolveVercelToken はトークンの解決優先順位(per-target > 環境変数 > top-level config)を実装する。
// perTargetToken が非空ならそれを返す。空なら top-level の ResolveVercelToken()(環境変数 > config)を使う。
func (cfg *AppConfig) resolveVercelToken(perTargetToken string) string {
Expand All @@ -334,6 +394,30 @@ func (cfg *AppConfig) resolveVercelToken(perTargetToken string) string {
return cfg.ResolveVercelToken()
}

// resolveVercelTokenWithSource はトークンと取得元を返す(per-target > 環境変数 > top-level config)。
func (cfg *AppConfig) resolveVercelTokenWithSource(perTargetToken string) (string, string) {
if perTargetToken != "" {
return perTargetToken, "config"
}
return cfg.ResolveVercelTokenWithSource()
}

// resolveVercelTeamIDWithSource はチーム ID と取得元を返す(per-target > 環境変数 > top-level config)。
func (cfg *AppConfig) resolveVercelTeamIDWithSource(perTargetTeamID string) (string, string) {
if perTargetTeamID != "" {
return perTargetTeamID, "config"
}
return cfg.ResolveVercelTeamIDWithSource()
}

// resolveGitHubTokenWithSource はトークンと取得元を返す(per-target > 環境変数 > top-level config)。
func (cfg *AppConfig) resolveGitHubTokenWithSource(perTargetToken string) (string, string) {
if perTargetToken != "" {
return perTargetToken, "config"
}
return cfg.ResolveGitHubTokenWithSource()
}

// resolveVercelTeamID はチーム ID の解決優先順位(per-target > 環境変数 > top-level config)を実装する。
// perTargetTeamID が非空ならそれを返す。空なら ResolveVercelTeamID()(環境変数 > config)を使う。
func (cfg *AppConfig) resolveVercelTeamID(perTargetTeamID string) string {
Expand Down Expand Up @@ -363,12 +447,18 @@ func (cfg *AppConfig) ResolveVercelTargets(selectName string) ([]VercelTarget, e
if selectName != "" {
return nil, fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectsNotDefined))
}
// 後方互換: 従来の単一解決
// 後方互換: 従来の単一解決(取得元も付与)
tok, tokSrc := cfg.ResolveVercelTokenWithSource()
pid, pidSrc := cfg.ResolveVercelProjectIDWithSource()
tid, tidSrc := cfg.ResolveVercelTeamIDWithSource()
return []VercelTarget{
{
ProjectID: cfg.ResolveVercelProjectID(),
TeamID: cfg.ResolveVercelTeamID(),
Token: cfg.ResolveVercelToken(),
ProjectID: pid,
ProjectIDSource: pidSrc,
TeamID: tid,
TeamIDSource: tidSrc,
Token: tok,
TokenSource: tokSrc,
},
}, nil
}
Expand All @@ -385,11 +475,16 @@ func (cfg *AppConfig) ResolveVercelTargets(selectName string) ([]VercelTarget, e
if selectName != "" && p.Name != selectName {
continue
}
tok, tokSrc := cfg.resolveVercelTokenWithSource(p.Token)
tid, tidSrc := cfg.resolveVercelTeamIDWithSource(p.TeamID)
targets = append(targets, VercelTarget{
Name: p.Name,
ProjectID: p.ProjectID,
TeamID: cfg.resolveVercelTeamID(p.TeamID),
Token: cfg.resolveVercelToken(p.Token),
Name: p.Name,
ProjectID: p.ProjectID,
ProjectIDSource: "config",
TeamID: tid,
TeamIDSource: tidSrc,
Token: tok,
TokenSource: tokSrc,
})
}

Expand Down Expand Up @@ -430,11 +525,15 @@ func (cfg *AppConfig) ResolveGitHubTargets(selectName string) ([]GitHubTarget, e
if selectName != "" {
return nil, fmt.Errorf("%s", i18n.T(i18n.MsgGitHubReposNotDefined))
}
// 後方互換: 従来の単一解決
// 後方互換: 従来の単一解決(取得元も付与)
tok, tokSrc := cfg.ResolveGitHubTokenWithSource()
repo, repoSrc := cfg.ResolveGitHubRepoWithSource()
return []GitHubTarget{
{
Repo: cfg.ResolveGitHubRepo(),
Token: cfg.ResolveGitHubToken(),
Repo: repo,
RepoSource: repoSrc,
Token: tok,
TokenSource: tokSrc,
},
}, nil
}
Expand All @@ -451,10 +550,13 @@ func (cfg *AppConfig) ResolveGitHubTargets(selectName string) ([]GitHubTarget, e
if selectName != "" && r.Name != selectName {
continue
}
tok, tokSrc := cfg.resolveGitHubTokenWithSource(r.Token)
targets = append(targets, GitHubTarget{
Name: r.Name,
Repo: r.Repo,
Token: cfg.resolveGitHubToken(r.Token),
Name: r.Name,
Repo: r.Repo,
RepoSource: "config",
Token: tok,
TokenSource: tokSrc,
})
}

Expand Down
Loading