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
35 changes: 35 additions & 0 deletions cli/internal/cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,44 @@ func newEnvCmd() *cobra.Command {
}
cmd.AddCommand(newEnvGenerateCmd())
cmd.AddCommand(newEnvPathCmd())
cmd.AddCommand(newEnvSetCmd())
return cmd
}

func newEnvSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set <KEY> <VALUE>",
Short: "Set a per-machine path override in machine.json (write-side of `env path`)",
Long: "set writes one structural path override into\n" +
"~/.config/dotfiles/machine.json — the write-side counterpart of `env path`.\n" +
"KEY must be a var declared in env-contract.json (an unknown key is rejected,\n" +
"so a typo cannot create a dead override no resolver reads); every other\n" +
"override is preserved and re-setting the same value is a no-op. First-run\n" +
"setup uses this to seed DOTFILES_REPO_DIR to the checkout it runs from, so the\n" +
"ADR-025 cascade resolves the real repo instead of the contract default.",
Args: cobra.ExactArgs(2),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
key, value := args[0], args[1]
contractPath := env.ResolveContractPath()
if contractPath == "" {
return fmt.Errorf("env-contract.json not found: set DOTFILES_DIR or run from the repo")
}
machinePath := env.MachinePath(env.Home())
changed, err := env.SetMachinePath(contractPath, machinePath, key, value)
if err != nil {
return err
}
if changed {
cmd.Printf("set %s in %s\n", key, machinePath)
} else {
cmd.Printf("unchanged %s (already %s)\n", key, value)
}
return nil
},
}
}

func newEnvPathCmd() *cobra.Command {
return &cobra.Command{
Use: "path <KEY>",
Expand Down
27 changes: 20 additions & 7 deletions cli/internal/cmd/mem.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,26 +147,39 @@ func cwdFromPayload(payload []byte) string {
return p.Cwd
}

// memScriptsDir locates the scripts/ dir hosting the sibling vault-health.sh,
// resolved from the env-contract (DOTFILES_REPO_DIR) — the Go equivalent of
// session-brief.sh's $(dirname "$0") sibling lookup. "" when unresolved, which
// makes vaultHealth emit the same "not found" line the shell does.
// memRepoDir resolves the dotfiles checkout for mem's sibling-script and config
// lookups: the ADR-025 cascade value when it names a real directory, else the
// .git walk-up (env.RepoDir). "" when neither resolves, so callers emit the same
// "not found" line the shell twin does — instead of probing the phantom contract
// default ~/Projects/dotfiles, which reads as "run setup" even after setup ran
// (#696).
func memRepoDir() string {
if r := env.ResolvePath("DOTFILES_REPO_DIR"); r != "" && dirExists(r) {
return r
}
return env.RepoDir()
}

// memScriptsDir locates the scripts/ dir hosting the sibling vault-health.sh —
// the Go equivalent of session-brief.sh's $(dirname "$0") sibling lookup. ""
// when unresolved, which makes vaultHealth emit the same "not found" line the
// shell does.
func memScriptsDir() string {
repo := env.ResolvePath("DOTFILES_REPO_DIR")
repo := memRepoDir()
if repo == "" {
return ""
}
return filepath.Join(repo, "scripts")
}

// memConfigPath resolves session-start-config.json: the SESSION_START_CONFIG
// override, else <DOTFILES_REPO_DIR>/session-start-config.json (the shell's
// override, else <checkout>/session-start-config.json (the shell's
// $SCRIPT_DIR/../session-start-config.json). "" falls back to historical defaults.
func memConfigPath() string {
if c := os.Getenv("SESSION_START_CONFIG"); c != "" {
return c
}
repo := env.ResolvePath("DOTFILES_REPO_DIR")
repo := memRepoDir()
if repo == "" {
return ""
}
Expand Down
28 changes: 21 additions & 7 deletions cli/internal/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,33 @@ Env:
return cmd
}

// repoForUpdate resolves the dotfiles checkout to fast-forward. The seam
// (ResolvePath: env → machine.json → contract default, ADR-025) is primary; the
// literal fallback is the last resort for a bare scheduler env (systemd --user /
// Task Scheduler) that lacks DOTFILES_REPO_DIR *and* cannot discover the
// contract. It reproduces the shell twins' '${DOTFILES_REPO_DIR:-$HOME/Projects/
// dotfiles}' exactly, so self-deploy stays self-contained under a timer.
// repoForUpdate resolves the dotfiles checkout to fast-forward. The cascade
// (ResolvePath: env → machine.json → contract default, ADR-025) is primary, but
// only when it points at a real directory: on a fresh machine with no
// machine.json the contract default is the phantom ~/Projects/dotfiles, which
// must not win over a discoverable checkout (#696). It then falls back to the
// .git walk-up (interactive `dotf update` from inside a checkout), and finally to
// the literal default for a bare scheduler env (systemd --user / Task Scheduler)
// with neither a seeded machine.json nor a discoverable repo — reproducing the
// shell twins' '${DOTFILES_REPO_DIR:-$HOME/Projects/dotfiles}'.
func repoForUpdate() string {
if r := env.ResolvePath("DOTFILES_REPO_DIR"); r != "" {
if r := env.ResolvePath("DOTFILES_REPO_DIR"); r != "" && dirExists(r) {
return r
}
if r := env.RepoDir(); r != "" {
return r
}
return filepath.Join(env.Home(), "Projects", "dotfiles")
}

// dirExists reports whether p exists and is a directory. Shared by the repo-dir
// resolvers in this package (update, mem) to reject a cascade value that names a
// path that is not actually there.
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}

// gitRunner returns a Git seam that runs `git -C <repo> <args...>` and returns
// trimmed stdout. On failure the error is returned (stdout ignored) — callers
// treat a failed git query as a skip condition, so surfacing stderr is not
Expand Down
45 changes: 45 additions & 0 deletions cli/internal/cmd/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cmd

import (
"os"
"path/filepath"
"testing"
)

// TestRepoForUpdatePrefersExistingCascadeDir: a cascade value that names a real
// directory is used as-is.
func TestRepoForUpdatePrefersExistingCascadeDir(t *testing.T) {
repo := t.TempDir()
t.Setenv("DOTFILES_REPO_DIR", repo)
if got := repoForUpdate(); got != repo {
t.Errorf("repoForUpdate() = %q, want existing cascade dir %q", got, repo)
}
}

// TestRepoForUpdateFallsBackToWalkUpWhenCascadeMissing: when the cascade resolves
// to a non-existent path (the #696 phantom-default class), repoForUpdate must
// fall through to the .git walk-up instead of returning the dead path.
func TestRepoForUpdateFallsBackToWalkUpWhenCascadeMissing(t *testing.T) {
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
sub := filepath.Join(repo, "cli")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("DOTFILES_REPO_DIR", filepath.Join(repo, "does-not-exist"))
t.Chdir(sub)

got, err := filepath.EvalSymlinks(repoForUpdate()) // tmp dirs may be symlinked (macOS)
if err != nil {
t.Fatal(err)
}
want, err := filepath.EvalSymlinks(repo)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("repoForUpdate() = %q, want walk-up root %q", got, want)
}
}
34 changes: 34 additions & 0 deletions cli/internal/doctor/checks_repodir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package doctor

import (
"path/filepath"

envpkg "github.com/mlorentedev/dotfiles/cli/internal/env"
)

// checkRepoDirResolves verifies the DOTFILES_REPO_DIR *cascade* (env ->
// machine.json -> contract default, ADR-025) points at a real dotfiles checkout.
//
// It resolves through envpkg.ResolvePath — the exact seam `dotf update` and
// `dotf mem` use — and deliberately does NOT apply the .git walk-up that
// resolveRepoDir (the deploy-drift check) does. The walk-up would find the
// checkout from doctor's own cwd and mask a phantom default; but update/mem run
// where no walk-up saves them (a systemd/Task-Scheduler timer, a session hook),
// so this check must fail exactly when those consumers would silently no-op on a
// fresh machine with an unseeded machine.json (#696).
func checkRepoDirResolves(rep *Report) {
rep.Section("Repo-dir resolution")
repo := envpkg.ResolvePath("DOTFILES_REPO_DIR")
switch {
case repo == "":
rep.Warn("DOTFILES_REPO_DIR does not resolve (no env var, machine.json override, or contract default)")
case !isDir(repo):
rep.Fail("DOTFILES_REPO_DIR resolves to a missing path: " + repo +
" — `dotf update`/`mem` will no-op; run setup (seeds machine.json) or `dotf env set DOTFILES_REPO_DIR <checkout>`")
case !isDir(filepath.Join(repo, ".git")):
rep.Fail("DOTFILES_REPO_DIR resolves to " + repo +
" which is not a git checkout — run setup or `dotf env set DOTFILES_REPO_DIR <checkout>`")
default:
rep.Pass("DOTFILES_REPO_DIR cascade resolves to a checkout: " + repo)
}
}
46 changes: 46 additions & 0 deletions cli/internal/doctor/checks_repodir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package doctor

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

// TestCheckRepoDirResolves drives the #696 guard: the DOTFILES_REPO_DIR cascade
// must resolve to a real git checkout. DOTFILES_REPO_DIR is set via the env
// (tier-1 of the cascade), so envpkg.ResolvePath returns it deterministically
// without a contract on disk.
func TestCheckRepoDirResolves(t *testing.T) {
realCheckout := t.TempDir()
if err := os.Mkdir(filepath.Join(realCheckout, ".git"), 0o755); err != nil {
t.Fatal(err)
}
notGit := t.TempDir() // exists but carries no .git

cases := []struct {
name string
repoDir string
wantFailures int
wantSubstr string
}{
{"real checkout -> pass", realCheckout, 0, "resolves to a checkout"},
{"missing path -> fail", filepath.Join(realCheckout, "nope"), 1, "missing path"},
{"exists but not a git checkout -> fail", notGit, 1, "not a git checkout"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DOTFILES_REPO_DIR", tc.repoDir)
var buf bytes.Buffer
rep := capture(&buf)
checkRepoDirResolves(rep)
if rep.Failures() != tc.wantFailures {
t.Fatalf("failures = %d, want %d\n%s", rep.Failures(), tc.wantFailures, buf.String())
}
if !strings.Contains(buf.String(), tc.wantSubstr) {
t.Fatalf("output missing %q\n%s", tc.wantSubstr, buf.String())
}
})
}
}
1 change: 1 addition & 0 deletions cli/internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func Run(opts Options) (int, error) {
checkOpenCode(sys, cfg, rep)
checkHarnessDrift(sys, cfg, rep)
checkDeployDrift(sys, cfg, rep)
checkRepoDirResolves(rep)
checkAntigravity(sys, rep)
checkOrcaHook(sys, rep)
}
Expand Down
84 changes: 84 additions & 0 deletions cli/internal/env/machine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package env

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)

// SetMachinePath merges one path override into machine.json — the write-side
// counterpart of the read-side ResolvePath / `dotf env path`. It loads the
// existing file (an absent file starts empty), validates key against the
// contract at contractPath (an undeclared key is rejected so a typo cannot
// silently create a dead override no resolver reads), sets paths[key]=value
// while preserving every other override, and writes the file back atomically
// (temp + rename in the same dir), creating the parent dir when absent. Returns
// changed=false when key was already set to value (an idempotent no-op, no
// write), true when the file was (re)written.
func SetMachinePath(contractPath, machinePath, key, value string) (bool, error) {
c, err := loadContract(contractPath)
if err != nil {
return false, err
}
if !contractHasVar(c, key) {
return false, fmt.Errorf("unknown path key %q: not declared in env-contract.json — check the spelling of the contract var", key)
}
m, err := loadMachine(machinePath)
if err != nil {
return false, err
}
if cur, ok := m.Paths[key]; ok && cur == value {
return false, nil // already set to this value — idempotent
}
m.Paths[key] = value
if err := writeMachine(machinePath, m); err != nil {
return false, err
}
return true, nil
}

// contractHasVar reports whether name is a declared structural var in the
// contract — the allowlist SetMachinePath validates against.
func contractHasVar(c *contract, name string) bool {
for _, v := range c.EnvVars {
if v.Name == name {
return true
}
}
return false
}

// writeMachine serializes m to path atomically: it writes a temp file in the
// same directory (so the rename is atomic on one filesystem) and renames it over
// path, so a concurrent reader never sees a half-written file. json.Marshal
// emits map keys sorted, keeping the file diff-friendly across runs.
func writeMachine(path string, m *machine) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create machine.json dir: %w", err)
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("marshal machine.json: %w", err)
}
data = append(data, '\n')

tmp, err := os.CreateTemp(dir, ".machine-*.json")
if err != nil {
return fmt.Errorf("create temp machine.json: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once the rename below succeeds
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp machine.json: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp machine.json: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename machine.json into place: %w", err)
}
return nil
}
Loading
Loading