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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,3 +32,6 @@ npm-debug.log*
website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f
_Avoid_: Custom rule, priority tweak

**Finding**:
A detected environment file with its assigned severity level, git status, and mitigation suggestions.
The detected environment file with its assigned severity level, git status, and mitigation suggestions.
_Avoid_: Vulnerability, issue, report item

**Initializer**:
The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`).
_Avoid_: Setup generator, config creator, scaffolder

**Sanitization**:
The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates.
_Avoid_: Masking, redacting, cleaning
23 changes: 18 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas
envguard check
```

### 3. Saída Estruturada em JSON
### 3. Inicialização de Configuração e Templates (`init`)

Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais:

```bash
# Inicializar .envguard.yaml padrão
envguard init

# Inicializar configuração e gerar template .env.example sanitizado
envguard init --template

# Inicializar em diretório específico sobrescrevendo arquivos existentes
envguard init --path ./meu-projeto --force
```

### 4. Saída Estruturada em JSON

```bash
envguard scan --format json
```

### 4. Verificar Versão
### 5. Verificar Versão

```bash
envguard version
Expand All@@ -118,8 +133,6 @@ envguard version
- **Padrões monitorados:** `.env`, `.env.*`, `*.env`
- **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template`

_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_

---

## Roadmap
Expand All@@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está
- [x] Relatórios em Terminal e JSON
- [x] Códigos de saída para CI/CD
- [ ] **v0.2.0:**
- [] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [x] `envguard init` (criação automática de `.envguard.yaml` e templates)
- [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`)
- [ ] Instalação de _Git Precommit Hooks_
- [ ] **v0.3.0:**
Expand Down
3 changes: 0 additions & 3 deletions docs/adr/0001-yaml-configuration-support.md

This file was deleted.

13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,9 @@ func (a *App) Run(args []string) int {
case "check":
return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner)

case "init":
return runInitCommand(args[1:], a.stdout, a.stderr)

default:
fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0])
a.printHelpTo(a.stderr)
Expand All@@ -87,6 +90,7 @@ Usage:
Available Commands:
scan Scan a directory for unprotected environment files
check Run verification optimized for CI/CD pipelines
init Initialize configuration file and safe template files
version Show current envguard version
help Show help for envguard commands

Expand All@@ -100,11 +104,20 @@ Scan & Check Flags:
-s, --severity Minimum severity level: info|warning|high|critical|all (default: "all")
--no-color Disable ANSI color escape codes in terminal output

Init Flags:
-p, --path Target directory path to initialize (default: ".")
-f, --force Overwrite existing configuration or template files
-t, --template Generate a safe .env.example template file
--template-from Source .env file to sanitize and create template from

Examples:
envguard scan
envguard scan --path ./my-project --format json
envguard scan --severity warning
envguard check --path . --severity high
envguard init
envguard init --template
envguard init --path ./my-project --force
envguard version
`
fmt.Fprint(w, help)
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/init.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
package cli

import (
"errors"
"flag"
"fmt"
"io"
"path/filepath"

"github.com/joaooncode/envguard/internal/initializer"
)

type initConfig struct {
path string
force bool
template bool
templateFrom string
}

func runInitCommand(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)

var cfg initConfig
fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize")
fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)")
fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files")
fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)")
fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file")
fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)")
fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return ExitCodeSuccess
}
return ExitCodeUsageError
}

if cfg.path == "" {
cfg.path = "."
}

// 1. Generate configuration file (.envguard.yaml)
if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
configFilePath := filepath.Join(cfg.path, ".envguard.yaml")
fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath)

// 2. Generate template if requested
if cfg.template || cfg.templateFrom != "" {
if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil {
fmt.Fprintf(stderr, "Error: %v\n", err)
return ExitCodeInternalError
}
templateFilePath := filepath.Join(cfg.path, ".env.example")
fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath)
}

return ExitCodeSuccess
}
141 changes: 141 additions & 0 deletions internal/cli/init_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
package cli_test

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/joaooncode/envguard/internal/cli"
)

func TestCLIInitHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--help"}, &stdout, &stderr)

if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
if !strings.Contains(stderr.String(), "Usage of init:") {
t.Errorf("expected usage output in stderr, got: %s", stderr.String())
}
}

func TestCLIInitDefault(t *testing.T) {
tmpDir := t.TempDir()
var stdout, stderr bytes.Buffer

code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

configPath := filepath.Join(tmpDir, ".envguard.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatalf("expected .envguard.yaml to be created at %s", configPath)
}

templatePath := filepath.Join(tmpDir, ".env.example")
if _, err := os.Stat(templatePath); !os.IsNotExist(err) {
t.Fatalf("expected .env.example NOT to be created when --template is not passed")
}

if !strings.Contains(stdout.String(), "Created configuration file:") {
t.Errorf("expected stdout to report created config, got: %s", stdout.String())
}
}

func TestCLIInitWithTemplate(t *testing.T) {
tmpDir := t.TempDir()

// Create dummy .env
envPath := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

content := string(data)
if strings.Contains(content, "mysecretvalue") {
t.Errorf("expected sensitive value to be stripped, got: %s", content)
}
if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") {
t.Errorf("expected keys to be preserved, got: %s", content)
}
}

func TestCLIInitWithTemplateFrom(t *testing.T) {
tmpDir := t.TempDir()
sourceEnv := filepath.Join(tmpDir, ".env.production")
if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}

templatePath := filepath.Join(tmpDir, ".env.example")
data, err := os.ReadFile(templatePath)
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}

if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") {
t.Errorf("unexpected template content: %s", string(data))
}
}

func TestCLIInitCollisionWithoutForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr)
if code != cli.ExitCodeInternalError {
t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code)
}

if !strings.Contains(stderr.String(), "already exists") {
t.Errorf("expected stderr to mention already exists, got: %s", stderr.String())
}
}

func TestCLIInitCollisionWithForce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".envguard.yaml")
if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr)
if code != cli.ExitCodeSuccess {
t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String())
}
}

func TestCLIInitInvalidFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr)
if code != cli.ExitCodeUsageError {
t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code)
}
}
Loading
Loading