dogsbay 0.2.0-beta.1 → 0.2.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * `dogsbay agent install` — wire skill discovery for an LLM agent
3
+ * (Claude Code, Cursor, Copilot, etc.).
4
+ *
5
+ * Bundled platform skills live at `<cli-install-dir>/skills/platform/*.md`.
6
+ * This command:
7
+ * 1. Resolves the bundled platform skills directory.
8
+ * 2. Symlinks them into `<project>/.dogsbay/skills/platform/`.
9
+ * 3. Creates `.dogsbay/skills/site/` (empty + README) and
10
+ * `.dogsbay/skills/plugins/` (empty placeholder).
11
+ * 4. For each requested --agent, writes the per-agent discovery
12
+ * path (`.claude/skills/dogsbay/`, `.cursor/rules/dogsbay/`).
13
+ *
14
+ * Re-running is idempotent — symlinks are recreated; existing
15
+ * site/ files are never touched.
16
+ *
17
+ * See plans/dogsbay-agent-skills.md for the four-tier ownership
18
+ * model and how this fits.
19
+ */
20
+ import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync, statSync, readlinkSync } from "node:fs";
21
+ import { dirname, join, relative, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import pc from "picocolors";
24
+ const SUPPORTED_AGENTS = ["claude", "cursor"];
25
+ const AGENT_TARGETS = {
26
+ claude: {
27
+ name: "claude",
28
+ skillsDir: ".claude/skills",
29
+ label: "Claude Code",
30
+ },
31
+ cursor: {
32
+ name: "cursor",
33
+ skillsDir: ".cursor/rules",
34
+ label: "Cursor",
35
+ },
36
+ };
37
+ /**
38
+ * Prefix every Dogsbay-shipped skill so it doesn't collide with
39
+ * the user's own skills under the same agent's discovery path.
40
+ */
41
+ const PLATFORM_PREFIX = "dogsbay-";
42
+ const SITE_PREFIX = "dogsbay-site-";
43
+ const PLUGIN_PREFIX = "dogsbay-plugin-";
44
+ export async function agentInstall(cwd, options) {
45
+ const projectRoot = resolve(cwd || ".");
46
+ // Resolve the bundled platform-skills directory. We're running
47
+ // from <cli-install>/dist/commands/agent.js, so walk up to
48
+ // <cli-install>/skills/platform/.
49
+ const here = dirname(fileURLToPath(import.meta.url));
50
+ const platformSkills = resolve(here, "..", "..", "skills", "platform");
51
+ if (!existsSync(platformSkills)) {
52
+ console.error(pc.red(`Error: bundled platform skills not found at ${platformSkills}.`));
53
+ console.error(` The dogsbay CLI install seems incomplete. Reinstall with`);
54
+ console.error(` 'npm install -g dogsbay@latest'.`);
55
+ process.exit(1);
56
+ }
57
+ // Pick the agents to install.
58
+ const agents = pickAgents(options);
59
+ if (agents.length === 0) {
60
+ printDetected(projectRoot);
61
+ return;
62
+ }
63
+ console.log(pc.cyan("→ Installing skill discovery"));
64
+ // 1. Always set up .dogsbay/skills/{platform,site,plugins}.
65
+ const dogsbayDir = join(projectRoot, ".dogsbay");
66
+ const skillsDir = join(dogsbayDir, "skills");
67
+ mkdirSync(skillsDir, { recursive: true });
68
+ const platformLink = join(skillsDir, "platform");
69
+ refreshSymlink(platformLink, platformSkills);
70
+ console.log(pc.green(` ✓ ${relative(projectRoot, platformLink)} → bundled platform skills`));
71
+ const siteDir = join(skillsDir, "site");
72
+ if (!existsSync(siteDir)) {
73
+ mkdirSync(siteDir, { recursive: true });
74
+ writeFileSync(join(siteDir, "README.md"), `# Site skills
75
+
76
+ This directory holds **site-specific** skills — your team's style
77
+ guide, voice / tone, terminology, glossary, internal conventions.
78
+ Anything an LLM should know that's specific to THIS site.
79
+
80
+ Each skill is a single \`.md\` file with frontmatter:
81
+
82
+ \`\`\`markdown
83
+ ---
84
+ name: site:style-guide
85
+ description: Our team's writing voice, terminology, and PR conventions.
86
+ ---
87
+
88
+ # Style guide
89
+
90
+ We use Oxford commas. Sentence-case headings. ...
91
+ \`\`\`
92
+
93
+ These skills are picked up automatically by any agent you've
94
+ installed via \`dogsbay agent install --agent <name>\`.
95
+
96
+ To override a platform skill (e.g. a different opinion on
97
+ \`nav-file.md\`), put your version under \`overrides/<skill-name>.md\`.
98
+ The agent loader checks overrides first.
99
+ `);
100
+ console.log(pc.green(` ✓ ${relative(projectRoot, siteDir)} created (empty + README)`));
101
+ }
102
+ else {
103
+ console.log(pc.gray(` · ${relative(projectRoot, siteDir)} already exists (preserved)`));
104
+ }
105
+ const pluginsDir = join(skillsDir, "plugins");
106
+ if (!existsSync(pluginsDir)) {
107
+ mkdirSync(pluginsDir, { recursive: true });
108
+ }
109
+ // 2. Per-agent discovery — symlink each skill INDIVIDUALLY at
110
+ // the top level of the agent's skills dir. Claude Code (and
111
+ // similar harnesses) discover skills as direct children of
112
+ // .claude/skills/, each containing a SKILL.md. Nested
113
+ // .claude/skills/dogsbay/platform/<skill>/ doesn't get scanned.
114
+ for (const agent of agents) {
115
+ const target = AGENT_TARGETS[agent];
116
+ const agentSkillsDir = join(projectRoot, target.skillsDir);
117
+ mkdirSync(agentSkillsDir, { recursive: true });
118
+ let installed = 0;
119
+ let removed = 0;
120
+ // Platform skills: dogsbay-<name>
121
+ for (const entry of readSkillDirs(join(skillsDir, "platform"))) {
122
+ const dest = join(agentSkillsDir, `${PLATFORM_PREFIX}${entry.name}`);
123
+ refreshSymlink(dest, entry.path);
124
+ installed++;
125
+ }
126
+ // Site skills: dogsbay-site-<name> (skipping overrides/ and the README)
127
+ for (const entry of readSkillDirs(join(skillsDir, "site"))) {
128
+ if (entry.name === "overrides")
129
+ continue;
130
+ const dest = join(agentSkillsDir, `${SITE_PREFIX}${entry.name}`);
131
+ refreshSymlink(dest, entry.path);
132
+ installed++;
133
+ }
134
+ // Plugin skills: dogsbay-plugin-<name>
135
+ for (const entry of readSkillDirs(join(skillsDir, "plugins"))) {
136
+ const dest = join(agentSkillsDir, `${PLUGIN_PREFIX}${entry.name}`);
137
+ refreshSymlink(dest, entry.path);
138
+ installed++;
139
+ }
140
+ // Sweep any stale dogsbay-* symlinks whose target no longer
141
+ // exists (e.g. user uninstalled a plugin, or a platform skill
142
+ // was renamed across CLI versions).
143
+ if (existsSync(agentSkillsDir)) {
144
+ for (const name of readdirSync(agentSkillsDir)) {
145
+ if (!name.startsWith("dogsbay-"))
146
+ continue;
147
+ const path = join(agentSkillsDir, name);
148
+ if (isBrokenSymlink(path)) {
149
+ try {
150
+ unlinkSync(path);
151
+ removed++;
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ }
156
+ }
157
+ console.log(pc.green(` ✓ ${target.label}: ${installed} skills under ${target.skillsDir}/`));
158
+ if (removed > 0) {
159
+ console.log(pc.gray(` (cleaned up ${removed} stale symlink${removed === 1 ? "" : "s"})`));
160
+ }
161
+ }
162
+ console.log("");
163
+ console.log(pc.cyan("Next:"));
164
+ console.log(" Open your editor — the agent should now see Dogsbay platform skills");
165
+ console.log(" on next prompt.");
166
+ console.log("");
167
+ console.log(" Add team-specific skills to .dogsbay/skills/site/.");
168
+ console.log(" Override a platform skill with .dogsbay/skills/site/overrides/<name>.md.");
169
+ }
170
+ /**
171
+ * Decide which agents to set up. Priority:
172
+ * --all → every supported agent
173
+ * --agent claude,cursor → exactly that list
174
+ * neither → return [], caller prints detected agents and exits
175
+ */
176
+ function pickAgents(options) {
177
+ if (options.all)
178
+ return [...SUPPORTED_AGENTS];
179
+ if (options.agent) {
180
+ const requested = options.agent.split(",").map((a) => a.trim().toLowerCase());
181
+ const valid = [];
182
+ for (const r of requested) {
183
+ if (SUPPORTED_AGENTS.includes(r)) {
184
+ valid.push(r);
185
+ }
186
+ else {
187
+ console.error(pc.yellow(` warn: unknown agent "${r}" (supported: ${SUPPORTED_AGENTS.join(", ")})`));
188
+ }
189
+ }
190
+ return valid;
191
+ }
192
+ return [];
193
+ }
194
+ /**
195
+ * When called without --agent or --all, just probe the project
196
+ * for known agent-config dirs and suggest commands.
197
+ */
198
+ function printDetected(projectRoot) {
199
+ const detected = [];
200
+ if (existsSync(join(projectRoot, ".claude"))) {
201
+ detected.push({ agent: "claude", signal: ".claude/" });
202
+ }
203
+ if (existsSync(join(projectRoot, ".cursor")) ||
204
+ existsSync(join(projectRoot, ".cursorrules"))) {
205
+ detected.push({ agent: "cursor", signal: ".cursor/ or .cursorrules" });
206
+ }
207
+ console.log(pc.cyan("Dogsbay agent install"));
208
+ console.log("");
209
+ console.log("Wires Dogsbay platform skills into the discovery path of an");
210
+ console.log("LLM agent so it picks them up on every prompt.");
211
+ console.log("");
212
+ if (detected.length > 0) {
213
+ console.log(pc.green("Detected in this project:"));
214
+ for (const d of detected) {
215
+ console.log(` ${d.agent.padEnd(8)} (${d.signal})`);
216
+ }
217
+ console.log("");
218
+ console.log("Run:");
219
+ for (const d of detected) {
220
+ console.log(` dogsbay agent install --agent ${d.agent}`);
221
+ }
222
+ console.log(" dogsbay agent install --all # set up every detected agent");
223
+ }
224
+ else {
225
+ console.log(pc.yellow("No supported agent configs detected in this project."));
226
+ console.log("");
227
+ console.log("Run:");
228
+ console.log(" dogsbay agent install --agent claude");
229
+ console.log(" dogsbay agent install --agent cursor");
230
+ console.log(" dogsbay agent install --all");
231
+ }
232
+ console.log("");
233
+ console.log(`Supported agents: ${SUPPORTED_AGENTS.join(", ")}`);
234
+ }
235
+ /**
236
+ * List each subdirectory of `dir` that looks like a skill — i.e.
237
+ * contains a SKILL.md file. Returns an empty array if `dir`
238
+ * doesn't exist or has no skill subdirs.
239
+ */
240
+ function readSkillDirs(dir) {
241
+ if (!existsSync(dir))
242
+ return [];
243
+ const out = [];
244
+ for (const name of readdirSync(dir)) {
245
+ if (name.startsWith("."))
246
+ continue;
247
+ const path = join(dir, name);
248
+ let isDir = false;
249
+ try {
250
+ isDir = statSync(path).isDirectory();
251
+ }
252
+ catch {
253
+ isDir = false;
254
+ }
255
+ if (!isDir)
256
+ continue;
257
+ if (!existsSync(join(path, "SKILL.md")))
258
+ continue;
259
+ out.push({ name, path });
260
+ }
261
+ return out;
262
+ }
263
+ /**
264
+ * Replace any existing entry at `linkPath` with a fresh symlink
265
+ * pointing at `target`. Idempotent: if the link already points at
266
+ * the right place, leaves it alone.
267
+ */
268
+ function refreshSymlink(linkPath, target) {
269
+ if (existsSync(linkPath) || isBrokenSymlink(linkPath)) {
270
+ try {
271
+ const current = readlinkSync(linkPath);
272
+ const resolved = resolve(dirname(linkPath), current);
273
+ if (resolved === target)
274
+ return; // already correct
275
+ }
276
+ catch {
277
+ // not a symlink
278
+ }
279
+ try {
280
+ unlinkSync(linkPath);
281
+ }
282
+ catch {
283
+ // ignore
284
+ }
285
+ }
286
+ symlinkSync(target, linkPath, "dir");
287
+ }
288
+ /**
289
+ * True only when `p` IS a symlink AND its target doesn't exist.
290
+ * Used by the cleanup pass to remove links pointing at gone paths
291
+ * (e.g. an uninstalled plugin or a renamed platform skill across
292
+ * CLI versions).
293
+ */
294
+ function isBrokenSymlink(p) {
295
+ try {
296
+ const lst = lstatSync(p);
297
+ if (!lst.isSymbolicLink())
298
+ return false;
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ // It's a symlink. existsSync follows the link; false → target gone.
304
+ return !existsSync(p);
305
+ }
@@ -66,11 +66,21 @@ export async function siteBuild(cwd, options) {
66
66
  console.error(` Run \`dogsbay site init ${siteRoot}\` first.`);
67
67
  process.exit(1);
68
68
  }
69
- // 5. Filter to primary sources (default) or load full matrix
70
- // (--publish). Primary-only is the fast path local iteration
71
- // and CI lint jobs use; --publish builds everything for the
72
- // deploy job. See plans/multi-source-content.md "Build modes".
73
- const mode = options.publish ? "all" : "primary";
69
+ // 5. Default to publish mode (every declared source builds) so
70
+ // multi-locale / multi-version / multi-namespace sites Just Work
71
+ // when the writer runs `dogsbay site build`. Use --primary-only
72
+ // for fast-iteration CI jobs that don't need the full matrix
73
+ // (rare). The `--publish` flag is kept as a deprecated no-op for
74
+ // older scripts.
75
+ //
76
+ // Previous default was "primary-only" — silently dropped non-
77
+ // primary sources from production builds. With i18n and version
78
+ // axes, that meant declaring `locales:` resulted in only the
79
+ // English source shipping, surprising every writer who ran
80
+ // `dogsbay site build` after declaring fr. Inverted to match the
81
+ // strongest signal: declaring more sources means you want them
82
+ // all. See plans/beta-launch-followups.md.
83
+ const mode = options.primaryOnly ? "primary" : "all";
74
84
  const originalSources = config.content.sources;
75
85
  const totalSources = originalSources.length;
76
86
  const { sources: activeSources, indices: activeIndices } = filterSourcesForMode(originalSources, mode);
@@ -231,21 +241,22 @@ export async function siteBuild(cwd, options) {
231
241
  console.warn(` Copy markdown + View as markdown work without it.`);
232
242
  }
233
243
  console.log(pc.green(`\nDone! Built ${generated} pages into ${outputDir}`));
234
- // When the writer has marked some sources as primary and we
235
- // built only those, surface the --publish hint. Skip the hint
236
- // for plain single-source builds and for full --publish runs
237
- // both already build everything.
244
+ // When the writer ran with --primary-only and we filtered the
245
+ // matrix, surface a hint that drops them back to the default.
246
+ // Skip for single-source / full-matrix builds both already
247
+ // built everything.
238
248
  if (mode === "primary" && primaryModeFiltersAnything(originalSources)) {
239
249
  const skipped = totalSources - activeIndices.length;
240
250
  console.log("");
241
- console.log(pc.dim(`Built ${activeIndices.length} primary source(s); ` +
242
- `${skipped} non-primary skipped. ` +
243
- `Run \`dogsbay site build --publish\` for the full matrix.`));
251
+ console.log(pc.dim(`--primary-only built ${activeIndices.length} of ${totalSources} ` +
252
+ `sources; ${skipped} non-primary skipped. ` +
253
+ `Drop --primary-only to build the full matrix.`));
244
254
  }
245
255
  console.log("");
246
256
  console.log(pc.cyan("Next:"));
247
- console.log(" npm run build # Astro build dist/");
248
- console.log(" dogsbay site dev # local preview with hot reload");
257
+ console.log(" dogsbay site dev # live preview at http://localhost:4321");
258
+ console.log(" dogsbay site preview # production build dist/ + serve");
259
+ console.log(" dogsbay site check # run audit rules");
249
260
  }
250
261
  // ─── Helpers ─────────────────────────────────────────────────────────────
251
262
  function resolveConfigPath(startDir, explicit) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dogsbay site dev` and `dogsbay site preview` — thin wrappers that
3
- * run `dogsbay site build` once, then hand control over to the Astro
4
- * CLI inside the site directory.
3
+ * run `dogsbay site build`, watch content for changes, and hand
4
+ * control over to the Astro CLI inside the site directory.
5
5
  *
6
6
  * dev → astro dev (live HMR for already-built pages)
7
7
  * preview → astro build && astro preview
@@ -11,14 +11,19 @@
11
11
  * existing script) is *not* what runs here — we shell out to `astro`
12
12
  * directly so this works whether the user uses pnpm / npm / yarn.
13
13
  *
14
- * Content file-watching is deferred. Today, editing source markdown
15
- * requires re-running `dogsbay site build` manually; Astro's dev
16
- * server reloads when the generated `.astro` files change.
14
+ * `site dev` also installs a content watcher that re-runs
15
+ * `dogsbay site build` whenever a markdown / yaml / json file under
16
+ * any configured source path changes (or `dogsbay.config.yml`
17
+ * itself). Astro's own dev server then hot-reloads the regenerated
18
+ * `.astro` pages. Without this, NEW files in `content/` weren't
19
+ * picked up by site dev — only edits to existing files surfaced
20
+ * (because Astro's watcher only sees `astro/src/`).
17
21
  */
18
22
  import { spawn } from "node:child_process";
19
23
  import { existsSync } from "node:fs";
20
24
  import { dirname, join, resolve } from "node:path";
21
25
  import pc from "picocolors";
26
+ import chokidar from "chokidar";
22
27
  import { findConfig, loadConfig, resolveOutputDir } from "../config/index.js";
23
28
  import { siteBuild } from "./site-build.js";
24
29
  const defaultRunner = (siteRoot, args) => new Promise((resolve) => {
@@ -32,19 +37,57 @@ const defaultRunner = (siteRoot, args) => new Promise((resolve) => {
32
37
  resolve(1);
33
38
  });
34
39
  });
40
+ /**
41
+ * Pick the first available package manager from a preference list.
42
+ * Defaults to pnpm (matches the dogsbay tooling chain); falls back
43
+ * to npm so machines without pnpm don't dead-end.
44
+ */
45
+ function pickPackageManager() {
46
+ const onPath = (cmd) => {
47
+ try {
48
+ const { execSync } = require("node:child_process");
49
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ if (onPath("pnpm"))
57
+ return "pnpm";
58
+ return "npm";
59
+ }
60
+ function runPackageManagerInstall(pm, cwd) {
61
+ return new Promise((resolve) => {
62
+ const child = spawn(pm, ["install"], { cwd, stdio: "inherit" });
63
+ child.on("exit", (code) => resolve(code ?? 0));
64
+ child.on("error", (err) => {
65
+ console.error(pc.red(`Error: failed to spawn ${pm}: ${err.message}`));
66
+ resolve(1);
67
+ });
68
+ });
69
+ }
35
70
  export async function siteDev(cwd, options, runner = defaultRunner) {
36
- const siteRoot = await prepareForAstro(cwd, options);
37
- const code = await runner(siteRoot, ["dev"]);
38
- process.exit(code);
71
+ const { siteRoot, outputDir } = await prepareForAstro(cwd, options);
72
+ const stopWatcher = startContentWatcher(siteRoot, outputDir, options);
73
+ try {
74
+ const code = await runner(outputDir, ["dev"]);
75
+ stopWatcher();
76
+ process.exit(code);
77
+ }
78
+ catch (err) {
79
+ stopWatcher();
80
+ throw err;
81
+ }
39
82
  }
40
83
  export async function sitePreview(cwd, options, runner = defaultRunner) {
41
- const siteRoot = await prepareForAstro(cwd, options);
84
+ const { outputDir } = await prepareForAstro(cwd, options);
42
85
  // Two-step: produce dist/ then serve it. Each spawn is independent
43
86
  // so we can still surface its exit code cleanly.
44
- const buildCode = await runner(siteRoot, ["build"]);
87
+ const buildCode = await runner(outputDir, ["build"]);
45
88
  if (buildCode !== 0)
46
89
  process.exit(buildCode);
47
- const previewCode = await runner(siteRoot, ["preview"]);
90
+ const previewCode = await runner(outputDir, ["preview"]);
48
91
  process.exit(previewCode);
49
92
  }
50
93
  async function prepareForAstro(cwd, options) {
@@ -61,9 +104,18 @@ async function prepareForAstro(cwd, options) {
61
104
  process.exit(1);
62
105
  }
63
106
  if (!existsSync(join(outputDir, "node_modules"))) {
64
- console.error(pc.red(`Error: dependencies not installed at ${outputDir}.`));
65
- console.error(` Run \`pnpm install\` (or \`npm install\`) in ${outputDir}.`);
66
- process.exit(1);
107
+ // First-run: install scaffolded site's deps automatically. Picks
108
+ // pnpm if available, otherwise npm. Friendly users shouldn't have
109
+ // to know this is a separate project under the hood — the
110
+ // dogsbay command should Just Work the first time.
111
+ const pm = pickPackageManager();
112
+ console.log(pc.cyan(`Installing scaffolded site deps (one-time, ~30s) — using ${pm}...`));
113
+ const installCode = await runPackageManagerInstall(pm, outputDir);
114
+ if (installCode !== 0) {
115
+ console.error(pc.red(`Error: ${pm} install failed in ${outputDir} (exit ${installCode}).`));
116
+ console.error(` Try running \`${pm} install\` manually in that directory.`);
117
+ process.exit(installCode);
118
+ }
67
119
  }
68
120
  // Refresh content / config-derived / agent-readiness files before
69
121
  // handing off to Astro. Skip with --no-build for fast iteration when
@@ -71,14 +123,140 @@ async function prepareForAstro(cwd, options) {
71
123
  if (!options.noBuild) {
72
124
  // Drafts visible during local preview — site dev is the writer's
73
125
  // iteration loop. Production `dogsbay site build` filters drafts.
74
- // Default mode is primary-only; `--full` opts into the publish
75
- // matrix for previewing switcher chrome.
126
+ // site dev defaults to primary-only for fast iteration; --full
127
+ // opts into the publish matrix for previewing switcher chrome.
128
+ // (Production `dogsbay site build` defaults to full matrix; only
129
+ // site dev / preview default to primary-only.)
76
130
  await siteBuild(siteRoot, {
77
131
  includeDrafts: true,
78
- publish: options.full === true,
132
+ primaryOnly: options.full !== true,
79
133
  });
80
134
  }
81
- return outputDir;
135
+ return { siteRoot, outputDir };
136
+ }
137
+ /**
138
+ * Watch the project's content paths + config file, and re-run
139
+ * `dogsbay site build` whenever something changes. Astro's own
140
+ * watcher then picks up the regenerated `astro/src/pages/*.astro`
141
+ * files and hot-reloads.
142
+ *
143
+ * Returns a cleanup fn that closes all the watchers.
144
+ *
145
+ * Debounces aggressively — many editors fire 3-5 fs events per save
146
+ * (write + rename + close-write etc.), and a single Vim save bursts
147
+ * across multiple files. 300ms is the sweet spot: low enough that
148
+ * the user feels the rebuild as immediate, high enough to coalesce
149
+ * a save burst into one rebuild.
150
+ */
151
+ function startContentWatcher(siteRoot, outputDir, options) {
152
+ // Re-load the config to know which paths to watch. We load it
153
+ // from disk on first event too (so config edits update the watch
154
+ // set on the fly).
155
+ let config = loadConfig(findOrFail(siteRoot, options.config));
156
+ // chokidar (vs Node's fs.watch) — fs.watch with `recursive: true`
157
+ // on Linux drops events from atomic-replace editor saves (Vim,
158
+ // VS Code, Helix all write tmp + rename), so nav.yml edits never
159
+ // surfaced as [dogsbay] rebuilds. chokidar handles atomic-replace
160
+ // correctly because it watches paths by name and re-arms on
161
+ // rename. `awaitWriteFinish` further coalesces multi-event saves
162
+ // into a single emit.
163
+ //
164
+ // Watch siteRoot recursively + each declared source path. The
165
+ // top-level `ignored` patterns keep us out of the output dir
166
+ // (otherwise rebuilds would self-trigger), VCS metadata, OS
167
+ // junk, and editor swap files.
168
+ const watchPaths = collectWatchPaths(siteRoot, config);
169
+ const watcher = chokidar.watch(watchPaths, {
170
+ ignoreInitial: true,
171
+ ignored: [
172
+ /(^|[\\/])\.git([\\/]|$)/,
173
+ /(^|[\\/])node_modules([\\/]|$)/,
174
+ /(^|[\\/])astro([\\/]|$)/,
175
+ /(^|[\\/])dist([\\/]|$)/,
176
+ /(^|[\\/])\.dogsbay([\\/]|$)/,
177
+ /\.swp$/,
178
+ /\.swo$/,
179
+ /~$/,
180
+ /\.DS_Store$/,
181
+ ],
182
+ awaitWriteFinish: {
183
+ stabilityThreshold: 50,
184
+ pollInterval: 10,
185
+ },
186
+ });
187
+ // Debounced rebuild loop. If a build is in flight when a new
188
+ // event arrives, mark dirty + rebuild after the current one
189
+ // finishes. Avoids overlapping builds racing on the same files.
190
+ let timer = null;
191
+ let building = false;
192
+ let dirty = false;
193
+ const scheduleBuild = () => {
194
+ dirty = true;
195
+ if (timer)
196
+ clearTimeout(timer);
197
+ timer = setTimeout(runBuild, 300);
198
+ };
199
+ const runBuild = async () => {
200
+ if (building)
201
+ return; // a build is in flight; the dirty flag handles re-run
202
+ if (!dirty)
203
+ return;
204
+ dirty = false;
205
+ building = true;
206
+ try {
207
+ console.log(pc.cyan("[dogsbay] content changed — rebuilding…"));
208
+ // Reload config to pick up dogsbay.config.yml edits.
209
+ config = loadConfig(findOrFail(siteRoot, options.config));
210
+ // Re-arm any newly-added source paths (chokidar dedupes
211
+ // already-watched roots internally).
212
+ const newPaths = collectWatchPaths(siteRoot, config);
213
+ watcher.add(newPaths);
214
+ await siteBuild(siteRoot, {
215
+ includeDrafts: true,
216
+ primaryOnly: options.full !== true,
217
+ });
218
+ console.log(pc.green("[dogsbay] rebuild complete"));
219
+ }
220
+ catch (err) {
221
+ console.error(pc.red(`[dogsbay] rebuild failed: ${err.message}`));
222
+ }
223
+ finally {
224
+ building = false;
225
+ // Coalesced changes during the build → run again.
226
+ if (dirty)
227
+ setImmediate(runBuild);
228
+ }
229
+ };
230
+ watcher.on("all", (_event, _path) => scheduleBuild());
231
+ // Diagnostic: show what's being watched on startup.
232
+ console.log(pc.gray(`[dogsbay] watching ${watchPaths.length} path${watchPaths.length === 1 ? "" : "s"} for content changes`));
233
+ return () => {
234
+ if (timer)
235
+ clearTimeout(timer);
236
+ void watcher.close();
237
+ };
238
+ }
239
+ /**
240
+ * Collect the absolute paths chokidar should watch: the site root
241
+ * (so `dogsbay.config.yml` edits trigger a reload) plus every
242
+ * declared `content.sources[].path`. Skips entries that don't exist
243
+ * on disk yet — chokidar would otherwise log a noisy ENOENT.
244
+ */
245
+ function collectWatchPaths(siteRoot, config) {
246
+ const out = new Set();
247
+ if (existsSync(siteRoot))
248
+ out.add(siteRoot);
249
+ for (const source of config.content?.sources ?? []) {
250
+ if (typeof source.path === "string") {
251
+ const abs = resolve(siteRoot, source.path);
252
+ if (existsSync(abs))
253
+ out.add(abs);
254
+ }
255
+ }
256
+ return [...out];
257
+ }
258
+ function findOrFail(siteRoot, explicit) {
259
+ return resolveConfigPath(siteRoot, explicit);
82
260
  }
83
261
  function resolveConfigPath(startDir, explicit) {
84
262
  if (explicit) {