diff --git a/AGENTS.md b/AGENTS.md index 205fda2..1064c9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,18 +284,11 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent No install script. Two paths: - **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done. -- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). The server virtually serves them at folder-name-driven paths: - - `archive.html` at every directory (multi-project, project, archive, vendor levels) - - `classifier.html` in any `Incoming`/`Working`/`Staging` directory and its subtree - - `mdedit.html` in any `Working` directory and its subtree - - `transmittal.html` in any `Staging` directory and its subtree - - `index.html` (landing) only at the deployment root - - See `internal/apps/availability.go`. Outside these locations, requesting `.html` returns 404 (just like any other missing file). +- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".** To override at any level, either: 1. Drop a real `.html` file at the path → static handler serves it (highest priority). -2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win. +2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.) URL sources fetch once and cache forever in `/_app//`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eda6f2d..2499616 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -690,14 +690,30 @@ Cascade evaluation walks leaf→root for the first level whose entries match the The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask. -#### Special folders +#### Canonical folders, URL routing & the `.zddc` cascade -Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`): +There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive//{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument. -- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators. -- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents. +The schema keys that drive built-in behavior: -The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after. +| Key | Effect | Cascade rule | +|---|---|---| +| `default_tool` | tool served at `` (no trailing slash) — the "specialized app" | leaf→root (parent applies to descendants) | +| `dir_tool` | tool served at `/` (trailing slash) — the directory view; floors at `browse` | leaf→root | +| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`: rwcda`); fenced adds `acl.inherit:false` (private) | leaf-only | +| `virtual` | never materialise on disk; requests are virtual routes (`reviewing/`, `mdl`) | leaf-only | +| `drop_target` | browse shows a drag-drop upload overlay (surfaced via `X-ZDDC-Drop-Target`) | leaf-only | +| `worm` | list of principals — see WORM below | union across cascade (no reset) | +| `available_tools` | tools the server may auto-serve / browse may offer here | union leaf→root | +| `admins` | subtree-admin principals (email globs or role names) | concat-dedupe across cascade | +| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk | +| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) | + +**Slash / no-slash URL routing.** Every directory URL has two forms: `/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive//mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side. + +**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive//{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change. + +**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working//` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule). ### File API (authenticated CRUD) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 2700455..9d8f2c5 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -574,6 +574,55 @@ func embeddedVersionsForLog(embedded map[string]string) string { return strings.Join(parts, " ") } +// serveSpecializedNoSlash handles a GET/HEAD request to a directory +// URL without a trailing slash by serving the directory's cascade- +// declared default_tool — the "specialized app" half of the slash/ +// no-slash routing convention. (The slash half is DirTool, resolved +// in handler.ServeDirectory; it defaults to "browse".) Works for both +// real on-disk directories and purely-virtual ones (default_tool may +// come from an ancestor's paths: tree). +// +// Returns true once it has written a response. Returns false when +// there is nothing specialized to serve — no default_tool, or +// default_tool=tables with no matching table spec, or the tool isn't +// available at this path — so the caller falls through to its own +// fallback (landing at a project root, then a 302 to the slash form +// where DirTool/browse renders the listing). +// +// ACL is enforced here against dirAbs's effective policy, so every +// default_tool route is gated identically regardless of which call +// site reached it. dirAbs is the directory's filesystem path (it need +// not exist on disk); urlPath is the request URL path; email is the +// authenticated user (may be empty). +func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool { + app := apps.DefaultAppAt(cfg.Root, dirAbs) + if app == "" { + return false + } + chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs) + if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return true + } + if app == "tables" { + // tables isn't an apps-subsystem app — it's the table view, + // served by handler.ServeTable from a synthesized + // /table.html request (which also resolves the embedded + // default-MDL spec for archive//mdl). No spec → caller + // falls through. + if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+"/table.html"); tr != nil { + handler.ServeTable(cfg, tr, w, r) + return true + } + return false + } + if appsSrv != nil && apps.AppAvailableAt(cfg.Root, dirAbs, app) { + appsSrv.Serve(w, r, app, chain, dirAbs) + return true + } + return false +} + // dispatch routes a request to the appropriate handler. func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) { // URL paths are case-insensitive: resolve each segment against the @@ -897,47 +946,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Cascade-declared paths: the .zddc cascade (embedded // defaults + on-disk overrides) declares this URL even // if the on-disk directory doesn't exist yet. Land on a - // usable view rather than 404 — usually the slash/no-slash - // convention serves: - // - no-slash, default_tool=tables → ServeTable - // (default-MDL fallback) - // - no-slash, default_tool set → apps.Serve(tool) - // - no-slash, no default_tool → 302 to slash form - // - slash, any → ServeDirectory - // (empty-listing fallback) + // usable view rather than 404, via the same slash/no-slash + // routing convention used for real directories: + // - slash → ServeDirectory (DirTool; browse by default) + // - no-slash → default_tool ("specialized app") if any, + // else a 302 to the slash form. if (r.Method == http.MethodGet || r.Method == http.MethodHead) && zddc.IsDeclaredPath(cfg.Root, absPath) { - chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) - if !strings.HasSuffix(urlPath, "/") { - // Tables special case: synthesize /table.html - // and run RecognizeTableRequest; the default-MDL - // fallback fires here for archive//mdl. - synth := urlPath + "/table.html" - if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { - if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - handler.ServeTable(cfg, tr, w, r) - return - } - // Generic default-tool routing for any other - // cascade-declared no-slash path. - if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil { - if apps.AppAvailableAt(cfg.Root, absPath, app) { - if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - appsSrv.Serve(w, r, app, chain, absPath) - return - } - } - // No default tool — fall through to slash form. - http.Redirect(w, r, urlPath+"/", http.StatusFound) + if strings.HasSuffix(urlPath, "/") { + handler.ServeDirectory(cfg, appsSrv, w, r) return } - handler.ServeDirectory(cfg, appsSrv, w, r) + if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) { + return + } + http.Redirect(w, r, urlPath+"/", http.StatusFound) return } http.Error(w, "Not Found", http.StatusNotFound) @@ -961,39 +984,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } - // URL convention: trailing slash → browse (handled by - // ServeDirectory, which serves browse.html for HTML requests - // and JSON for application/json). No trailing slash → the - // canonical default tool for this directory's context, if any - // (mdedit under working/, transmittal under staging/, archive - // under archive/, tables under archive//mdl/). When no - // default applies, fall back to the historical redirect-to- - // trailing-slash behaviour. + // Slash/no-slash routing convention: trailing slash → the + // directory view (handler.ServeDirectory → DirTool, which + // resolves to browse by default; JSON requests always get the + // raw listing regardless). No trailing slash → the directory's + // default_tool ("specialized app") — mdedit under working/, + // transmittal under staging/, archive under archive/, tables + // under archive//mdl/ — if one is declared; otherwise + // (after the project-root landing case below) a 302 to the + // slash form. if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot { - app := apps.DefaultAppAt(cfg.Root, absPath) - switch app { - case "": - // no default tool — fall through to the historical - // redirect-to-trailing-slash below. - case "tables": - // Tables aren't an apps-subsystem app — the table - // handler responds to //table.html. Serve the - // equivalent table view inline at the bare URL by - // synthesizing the canonical /table.html suffix. - synth := urlPath + "/table.html" - if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { - handler.ServeTable(cfg, tr, w, r) - return - } - default: - // Any other cascade-declared tool: serve via the apps - // subsystem when available (gated by AvailableTools - // in the cascade). - if appsSrv != nil && apps.AppAvailableAt(cfg.Root, absPath, app) { - chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) - appsSrv.Serve(w, r, app, chain, absPath) - return - } + if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) { + return } } // Project root (depth-1 dir, no trailing slash) serves the diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 460f1c7..ff69691 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -171,15 +171,20 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit return } - // Browser HTML fallback: serve the `browse` tool. By default it's - // the embedded copy (single-file SPA whose autoDetectServerMode - // loads the JSON listing for the current directory and renders it - // as a sortable, filterable tree). A `.zddc apps: browse:` entry - // up the chain can override with a path or URL source — when - // appsSrv is wired up, delegate to it so cascade entries are - // honored at directory URLs too (not just //browse.html). - if appsSrv != nil { - appsSrv.Serve(w, r, "browse", chain, absDir) + // Browser HTML fallback: serve the directory's DirTool — the + // trailing-slash half of the slash/no-slash convention. It + // resolves to "browse" by default (the single-file file-tree SPA + // whose autoDetectServerMode loads the JSON listing for the + // current directory and renders it as a sortable, filterable + // tree); an operator's `.zddc dir_tool:` can point a subtree's + // slash form at another directory-oriented tool. Either way it + // goes through the apps subsystem when wired up, so `.zddc apps:` + // source overrides are honored at directory URLs too (not just + // //.html). When appsSrv is nil we serve the embedded + // browse copy directly — same behavior as before the hook. + dirTool := zddc.DirToolAt(cfg.Root, absDir) + if appsSrv != nil && zddc.IsKnownApp(dirTool) { + appsSrv.Serve(w, r, dirTool, chain, absDir) return } body := apps.EmbeddedBytes("browse") diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 7b79559..8690aa6 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -60,6 +60,32 @@ roles: # ancestor entries — so this baseline propagates to every descendant. available_tools: [archive, browse, landing] +# ── The slash / no-slash routing convention ──────────────────────────────── +# +# Every directory URL has two forms, each served by a configurable +# tool: +# +# / (trailing slash) → `dir_tool` — the directory view. +# Defaults to `browse` (file-tree +# navigator). This is the site-wide +# default; you rarely set it. +# (no slash) → `default_tool` — the "specialized +# app" for this folder (e.g. archive, +# transmittal, mdedit, tables). If a +# folder declares no default_tool, the +# no-slash form just 302s to the slash +# form, so you land on `dir_tool`. +# +# JSON listing requests are unaffected by either key — they always get +# the raw directory listing, so the browse SPA (and any other client) +# can enumerate entries no matter what dir_tool/default_tool are. +# +# Both keys cascade leaf→root: a parent's default_tool applies to +# descendants unless a deeper level overrides it (mdedit set on +# working/ reaches working/alice/notes/ for free). The keys below set +# default_tool on the canonical folders; dir_tool is left unset +# everywhere, so the slash form is always `browse`. +# # ── Canonical project structure ──────────────────────────────────────────── # # Every ZDDC project lives at a top-level directory. Under it the diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 85cb61a..742ded4 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -186,12 +186,25 @@ type ZddcFile struct { // DefaultTool is the tool name served at this directory's // no-slash URL form (e.g. /Project/working without trailing slash - // → mdedit). Empty means "no default" — the slash convention's - // browse listing wins and the no-slash form 302s. Cascades - // through Paths: an ancestor's Paths entry can set DefaultTool - // for a virtual descendant without anyone creating that dir. + // → mdedit). Empty means "no default" — the no-slash form 302s to + // the slash form, which serves DirTool (browse by default). + // Cascades through Paths: an ancestor's Paths entry can set + // DefaultTool for a virtual descendant without anyone creating + // that dir. This is the "specialized app" half of the slash/no- + // slash convention; see DirTool for the other half. DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"` + // DirTool is the tool name served at this directory's TRAILING- + // SLASH URL form (e.g. /Project/working/ → the directory view). + // Empty resolves to "browse" — the file-tree navigator — which is + // the site-wide default. An operator can override it per subtree + // (rare; e.g. a folder whose slash form should render some other + // directory-oriented view). JSON listing requests are unaffected: + // they always get the raw listing regardless of DirTool, so the + // browse SPA (or any client) can still enumerate entries. + // Cascades leaf→root like DefaultTool. + DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"` + // AutoOwn controls whether the file API's mkdir post-hook writes // an auto-owned .zddc granting the creator rwcda at the new // directory. Useful for working/staging/incoming-style drafting diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 1e6fd36..90266a1 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -30,6 +30,31 @@ func DefaultToolAt(fsRoot, dirPath string) string { return chain.Embedded.DefaultTool } +// DirToolAt returns the cascade-resolved tool name served at the +// directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root +// (then the embedded defaults), returning the first non-empty +// DirTool. Floors at "browse": an undeclared directory serves the +// file-tree navigator, which is the site-wide convention. So callers +// never need to special-case the empty result. +// +// This is the slash half of the slash/no-slash routing convention; +// DefaultToolAt is the no-slash half. +func DirToolAt(fsRoot, dirPath string) string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return "browse" + } + for i := len(chain.Levels) - 1; i >= 0; i-- { + if dt := chain.Levels[i].DirTool; dt != "" { + return dt + } + } + if dt := chain.Embedded.DirTool; dt != "" { + return dt + } + return "browse" +} + // AutoOwnAt reports whether mkdir at THIS specific directory should // write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT // propagate to descendants (creating working/alice/notes/sub/ does @@ -216,7 +241,7 @@ func isZeroZddcFile(zf ZddcFile) bool { if zf.Title != "" { return false } - if zf.DefaultTool != "" { + if zf.DefaultTool != "" || zf.DirTool != "" { return false } if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 4c1f68c..71a7d83 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -35,6 +35,42 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) { } } +// TestDirToolAt — the trailing-slash form floors at "browse" for +// every path (the embedded convention sets dir_tool nowhere), and an +// on-disk .zddc can override it for a subtree. +func TestDirToolAt(t *testing.T) { + resetCache() + root := t.TempDir() + // Nothing declares dir_tool → browse everywhere, including paths + // whose default_tool (no-slash form) is something else. + for _, p := range []string{ + filepath.Join(root, "Project-X"), + filepath.Join(root, "Project-X", "working"), + filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), + filepath.Join(root, "Project-X", "random", "deep", "folder"), + } { + if got := DirToolAt(root, p); got != "browse" { + t.Errorf("DirToolAt(%q) = %q, want browse", p[len(root):], got) + } + } + // Operator override on a subtree; cascades leaf→root. + specialDir := filepath.Join(root, "Special") + if err := os.MkdirAll(specialDir, 0o755); err != nil { + t.Fatal(err) + } + writeZddc(t, specialDir, "dir_tool: tables\n") + resetCache() + if got := DirToolAt(root, filepath.Join(root, "Special")); got != "tables" { + t.Errorf("DirToolAt(Special) = %q, want tables", got) + } + if got := DirToolAt(root, filepath.Join(root, "Special", "deep")); got != "tables" { + t.Errorf("DirToolAt(Special/deep) = %q, want tables (cascade)", got) + } + if got := DirToolAt(root, filepath.Join(root, "Other")); got != "browse" { + t.Errorf("DirToolAt(Other) = %q, want browse (override scoped to Special)", got) + } +} + // TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for // working/incoming/staging (per the convention) and false elsewhere. func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 8c9f917..2c5debb 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -69,6 +69,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.DefaultTool != "" { out.DefaultTool = top.DefaultTool } + if top.DirTool != "" { + out.DirTool = top.DirTool + } if top.AutoOwn != nil { out.AutoOwn = top.AutoOwn }