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.
- package/dist/commands/agent.js +305 -0
- package/dist/commands/site-build.js +25 -14
- package/dist/commands/site-dev.js +196 -18
- package/dist/commands/site-init.js +193 -35
- package/dist/config/defaults.js +8 -1
- package/dist/index.js +17 -2
- package/package.json +11 -9
- package/skills/platform/agent-readiness/SKILL.md +262 -0
- package/skills/platform/cli-commands/SKILL.md +205 -0
- package/skills/platform/config-yml/SKILL.md +219 -0
- package/skills/platform/frontmatter-fields/SKILL.md +310 -0
- package/skills/platform/markdown-directives/SKILL.md +253 -0
- package/skills/platform/multi-source/SKILL.md +294 -0
- package/skills/platform/nav-file/SKILL.md +107 -0
- package/skills/platform/openapi-source/SKILL.md +237 -0
- package/skills/platform/plugin-api/SKILL.md +280 -0
- package/skills/platform/project-anatomy/SKILL.md +156 -0
- package/skills/platform/taxonomy-config/SKILL.md +392 -0
- package/skills/platform/theme-tokens/SKILL.md +276 -0
|
@@ -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.
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
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
|
|
235
|
-
//
|
|
236
|
-
// for
|
|
237
|
-
//
|
|
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(
|
|
242
|
-
|
|
243
|
-
`
|
|
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("
|
|
248
|
-
console.log(" dogsbay site
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
38
|
-
|
|
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
|
|
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(
|
|
87
|
+
const buildCode = await runner(outputDir, ["build"]);
|
|
45
88
|
if (buildCode !== 0)
|
|
46
89
|
process.exit(buildCode);
|
|
47
|
-
const previewCode = await runner(
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|