Skip to content
Open
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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Example environment variables for xc tasks
# Copy this to .env and customize for your environment

# Example: API keys
# API_KEY=your_api_key_here

# Example: Database connection
# DATABASE_URL=postgres://localhost/mydb

# Example: Environment
# ENV=development
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ coverage.out
dist/
doc/public/
result

# Environment files
.env
.env.local
.env.*.local
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,97 @@ Find our guidelines & HOWTOs at [CONTRIBUTING.md](https://github.com/joerdav/xc/
![emacs demo](https://codeberg.org/ryanprior/xc.el/media/branch/main/screenshot-v1.png)
- See also: [`org-mode` specific features](https://xcfile.dev/org-mode-features).


# Environment Variables

xc supports loading environment variables from `.env` files, making it easy to manage different configurations without cluttering your task definitions.

## Basic Usage

Create a `.env` file in your project root:

```
API_KEY=your_api_key_here
DATABASE_URL=postgres://localhost/mydb
ENV=development
```

xc will automatically load these variables before running tasks. They will be available to all tasks in your documentation file.

## Load Order

Environment variables are loaded in the following order (later values override earlier ones):

1. System environment variables
2. `.env` file (if present)
3. `.env.local` file (if present)
4. Task-level `Env:` statements
5. Command-line input values
6. Inline `export` statements in task scripts

This allows you to:
- Set defaults in `.env`
- Override with local values in `.env.local` (great for secrets, add to `.gitignore`)
- Still use task-level env for task-specific configuration

## CLI Options

```bash
# Skip loading .env files
xc --no-env <task>

# Load a custom env file
xc --env-file .env.prod <task>
```

## Security

For security, xc will skip `.env` files with world-readable or group-readable permissions and log a warning. Ensure your `.env` files have restricted permissions:

```bash
chmod 600 .env
chmod 600 .env.local
```

## Example

**Before** (cluttered task definition):
```markdown
## deploy

Deploy the application.

Env: DATABASE_URL=postgres://prod/db, API_KEY=secret123, ENV=production

\```
./deploy.sh
\```
```

**After** (with .env):
```markdown
## deploy

Deploy the application.

\```
./deploy.sh
\```
```

With `.env` file:
```
DATABASE_URL=postgres://prod/db
API_KEY=secret123
ENV=production
```

And `.env.local` for local overrides (gitignored):
```
DATABASE_URL=postgres://localhost/db
ENV=development
```

# Example

Take the `tag` task in the [README.md](https://github.com/joerdav/xc#tag) of the `xc` repository:
Expand Down
42 changes: 34 additions & 8 deletions cmd/xc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"runtime/debug"
"strings"

"github.com/joerdav/xc/internal/dotenv"
"github.com/joerdav/xc/models"
"github.com/joerdav/xc/parser/parsemd"
"github.com/joerdav/xc/parser/parseorg"
Expand All @@ -30,8 +31,8 @@ var usage string
var ErrNoTaskFile = errors.New("no xc compatible documentation file found")

type config struct {
version, help, short, display, noTTY, complete, uncomplete bool
filename, heading, filetype string
version, help, short, display, noTTY, complete, uncomplete, noEnv bool
filename, heading, filetype, envFile string
}

var version = ""
Expand Down Expand Up @@ -76,6 +77,9 @@ func flags() config {

flag.BoolVar(&cfg.noTTY, "no-tty", false, "disable interactive picker")

flag.BoolVar(&cfg.noEnv, "no-env", false, "skip loading .env files")
flag.StringVar(&cfg.envFile, "env-file", "", "load environment from specified file")

flag.Parse()
return cfg
}
Expand Down Expand Up @@ -215,27 +219,49 @@ func runMain() error {
<-c
cancel()
}()

cfg := flags()

// Early exits (don't load .env for these)
if cfg.uncomplete {
return install.Uninstall("xc")
}
if cfg.complete {
return install.Install("xc")
}
tasks, dir, err := parse(cfg.filename, cfg.heading, cfg.filetype)
// TODO remove the Interactive attribute & this deprecation warning
warnInteractive(tasks)
completion(tasks).Complete("xc")
// xc -version
if cfg.version {
fmt.Printf("xc version: %s\n", getVersion())
return nil
}
// xc -h / xc -help
if cfg.help {
flag.Usage()
return nil
}

// Load .env files before parsing tasks (unless --no-env is set)
if !cfg.noEnv {
cwd, err := os.Getwd()
if err != nil {
log.Printf("warning: failed to get current directory: %v", err)
} else {
if cfg.envFile != "" {
// Load custom env file
if err := dotenv.LoadFile(filepath.Join(cwd, cfg.envFile)); err != nil {
log.Printf("warning: failed to load %s: %v", cfg.envFile, err)
}
} else {
// Load default .env and .env.local
if err := dotenv.Load(cwd); err != nil {
log.Printf("warning: failed to load .env: %v", err)
}
}
}
}

tasks, dir, err := parse(cfg.filename, cfg.heading, cfg.filetype)
// TODO remove the Interactive attribute & this deprecation warning
warnInteractive(tasks)
completion(tasks).Complete("xc")
if err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ require (
github.com/charmbracelet/bubbletea v0.24.2
github.com/charmbracelet/lipgloss v0.7.1
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/joho/godotenv v1.5.1
github.com/posener/complete/v2 v2.0.1-alpha.13
golang.org/x/term v0.8.0
mvdan.cc/sh/v3 v3.7.0
)

Expand All @@ -30,6 +32,5 @@ require (
github.com/sahilm/fuzzy v0.1.0 // indirect
golang.org/x/sync v0.2.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/term v0.8.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
16 changes: 3 additions & 13 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:Yyn
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
Expand All @@ -24,8 +23,11 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
Expand Down Expand Up @@ -54,36 +56,24 @@ github.com/posener/script v1.1.5/go.mod h1:Rg3ijooqulo05aGLyGsHoLmIOUzHUVK19WVgr
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
mvdan.cc/sh/v3 v3.6.0 h1:gtva4EXJ0dFNvl5bHjcUEvws+KRcDslT8VKheTYkbGU=
mvdan.cc/sh/v3 v3.6.0/go.mod h1:U4mhtBLZ32iWhif5/lD+ygy1zrgaQhUu+XFy7C8+TTA=
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
72 changes: 72 additions & 0 deletions internal/dotenv/loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package dotenv

import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"runtime"

"github.com/joho/godotenv"
)

// Load loads .env files from the specified directory.
// Loads .env first, then .env.local (which overrides .env values).
// If neither file exists, no error is returned.
// Files with world-readable permissions are skipped with a warning.
func Load(dir string) error {
// Load .env
envPath := filepath.Join(dir, ".env")
if err := loadFile(envPath, false); err != nil {
return err
}

// Load .env.local (overrides .env)
localPath := filepath.Join(dir, ".env.local")
if err := loadFile(localPath, true); err != nil {
return err
}

return nil
}

// LoadFile loads a single environment file (for custom --env-file flag).
func LoadFile(path string) error {
return loadFile(path, false)
}

// loadFile loads a single env file with security checks.
// If override is true, uses Overload instead of Load.
func loadFile(path string, override bool) error {
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return nil // File not found is OK
}
if err != nil {
return fmt.Errorf("failed to check %s: %w", path, err)
}

// Security check: Skip world-readable or group-readable files (Unix only)
if runtime.GOOS != "windows" {
perm := info.Mode().Perm()
// Check if world-readable (others can read: 0004) or group-readable (0040)
if perm&0044 != 0 {
log.Printf("warning: %s is world/group readable (permissions: %o), skipping for security", path, perm)
return nil
}
}

// Load the file
if override {
if err := godotenv.Overload(path); err != nil {
return fmt.Errorf("failed to load %s: %w", path, err)
}
return nil
}

if err := godotenv.Load(path); err != nil {
return fmt.Errorf("failed to load %s: %w", path, err)
}
return nil
}
Loading