@doquflow/cli 0.4.6 → 0.5.2
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/init.js +58 -0
- package/dist/commands/sync.js +361 -0
- package/dist/commands/watch-stop.js +223 -0
- package/dist/commands/watch.js +587 -0
- package/dist/index.js +92 -8
- package/package.json +2 -2
package/dist/commands/init.js
CHANGED
|
@@ -386,6 +386,8 @@ async function run() {
|
|
|
386
386
|
await promises_1.default.appendFile(gitignorePath, "\n# Docuflow\n.docuflow/\n");
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
|
+
// Install git post-commit hook (auto-sync on every commit)
|
|
390
|
+
await installGitHook(projectDir);
|
|
389
391
|
console.log("\u2713 DocuFlow initialised successfully.");
|
|
390
392
|
console.log("");
|
|
391
393
|
console.log("\ud83d\udcc1 Structure created:");
|
|
@@ -416,4 +418,60 @@ async function run() {
|
|
|
416
418
|
console.log(" 1. Edit .docuflow/schema.md to customize your wiki domain");
|
|
417
419
|
console.log(" 2. Add markdown docs to .docuflow/sources/ then ingest them");
|
|
418
420
|
console.log(" 3. Restart Claude Desktop / reload VS Code / restart Copilot CLI");
|
|
421
|
+
console.log("");
|
|
422
|
+
console.log("\u26a1 Auto-sync options:");
|
|
423
|
+
console.log(" docuflow watch # background daemon (watches for file changes)");
|
|
424
|
+
console.log(" docuflow watch --ai # + Claude/Codex documents code changes automatically");
|
|
425
|
+
console.log(" docuflow sync # one-shot sync (good for CI/CD)");
|
|
426
|
+
console.log(" docuflow sync --ai # + AI generates docs from last git commit");
|
|
427
|
+
console.log("");
|
|
428
|
+
console.log(" A git post-commit hook was installed at .git/hooks/post-commit");
|
|
429
|
+
console.log(" It runs \"docuflow sync --ai --quiet\" after every commit automatically.");
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Install a git post-commit hook that runs `docuflow sync` automatically.
|
|
433
|
+
* Idempotent — does not overwrite a hook that already has DocuFlow content.
|
|
434
|
+
*/
|
|
435
|
+
async function installGitHook(projectDir) {
|
|
436
|
+
const gitDir = node_path_1.default.join(projectDir, ".git");
|
|
437
|
+
if (!node_fs_1.default.existsSync(gitDir))
|
|
438
|
+
return; // not a git repo
|
|
439
|
+
const hooksDir = node_path_1.default.join(gitDir, "hooks");
|
|
440
|
+
await promises_1.default.mkdir(hooksDir, { recursive: true });
|
|
441
|
+
const hookPath = node_path_1.default.join(hooksDir, "post-commit");
|
|
442
|
+
const hookMarker = "# docuflow-auto-sync";
|
|
443
|
+
// Don't overwrite if already installed
|
|
444
|
+
if (node_fs_1.default.existsSync(hookPath)) {
|
|
445
|
+
const existing = await promises_1.default.readFile(hookPath, "utf8");
|
|
446
|
+
if (existing.includes(hookMarker))
|
|
447
|
+
return;
|
|
448
|
+
// Append to existing hook
|
|
449
|
+
const appendContent = [
|
|
450
|
+
"",
|
|
451
|
+
hookMarker,
|
|
452
|
+
`# Auto-generated by docuflow init — runs wiki sync after every commit`,
|
|
453
|
+
`# Requires Claude CLI, Codex CLI, or ANTHROPIC_API_KEY for AI-powered doc generation`,
|
|
454
|
+
`# Remove the lines below to disable auto-sync`,
|
|
455
|
+
`if command -v docuflow &> /dev/null; then`,
|
|
456
|
+
` docuflow sync --ai --quiet &`,
|
|
457
|
+
`fi`,
|
|
458
|
+
].join("\n");
|
|
459
|
+
await promises_1.default.appendFile(hookPath, appendContent + "\n");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// Create new hook file
|
|
463
|
+
const hookContent = [
|
|
464
|
+
`#!/bin/sh`,
|
|
465
|
+
hookMarker,
|
|
466
|
+
`# Auto-generated by docuflow init`,
|
|
467
|
+
`# Syncs the DocuFlow wiki after every commit using Claude / Codex / Anthropic API`,
|
|
468
|
+
`# AI bridge priority: Claude CLI > Codex CLI > ANTHROPIC_API_KEY`,
|
|
469
|
+
`# Run in background (&) so it never delays your git workflow`,
|
|
470
|
+
`if command -v docuflow &> /dev/null; then`,
|
|
471
|
+
` docuflow sync --ai --quiet &`,
|
|
472
|
+
`fi`,
|
|
473
|
+
].join("\n");
|
|
474
|
+
await promises_1.default.writeFile(hookPath, hookContent + "\n", "utf8");
|
|
475
|
+
// Make executable
|
|
476
|
+
node_fs_1.default.chmodSync(hookPath, 0o755);
|
|
419
477
|
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* docuflow sync
|
|
4
|
+
*
|
|
5
|
+
* One-shot synchronisation — re-ingests all sources, rebuilds the index,
|
|
6
|
+
* and runs a lint health check. Designed for CI/CD pipelines and git hooks.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* docuflow sync # sync all sources, rebuild index, lint
|
|
10
|
+
* docuflow sync --ai # also call Claude/Codex for changed code
|
|
11
|
+
* docuflow sync --since-commit HEAD~1 # only process files changed in last commit
|
|
12
|
+
* docuflow sync --source myfile.md # sync a single source file
|
|
13
|
+
* docuflow sync --no-lint # skip health check (faster for CI)
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* 0 — success, wiki is healthy (score ≥ 70)
|
|
17
|
+
* 1 — wiki has issues (score < 70) — use to fail CI
|
|
18
|
+
* 2 — fatal error (server tools not found, .docuflow missing)
|
|
19
|
+
*/
|
|
20
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
21
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
22
|
+
};
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.run = run;
|
|
25
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
26
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
27
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
28
|
+
const node_child_process_1 = require("node:child_process");
|
|
29
|
+
const watch_1 = require("./watch");
|
|
30
|
+
// ─── Colour helpers ────────────────────────────────────────────────────────────
|
|
31
|
+
const c = {
|
|
32
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
33
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
34
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
35
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
36
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
37
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
38
|
+
};
|
|
39
|
+
// ─── Dynamic server tool loader ────────────────────────────────────────────────
|
|
40
|
+
function loadServerTool(toolFile) {
|
|
41
|
+
const candidates = [
|
|
42
|
+
() => require(`@doquflow/server/dist/tools/${toolFile}`),
|
|
43
|
+
() => require(node_path_1.default.resolve(__dirname, "../../../server/dist/tools", toolFile)),
|
|
44
|
+
() => require(node_path_1.default.resolve(__dirname, "../../server/dist/tools", toolFile)),
|
|
45
|
+
];
|
|
46
|
+
for (const attempt of candidates) {
|
|
47
|
+
try {
|
|
48
|
+
return attempt();
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Cannot load server tool "${toolFile}". Run "npm run build" first.`);
|
|
53
|
+
}
|
|
54
|
+
// ─── Git helpers ───────────────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Get list of source files changed since a given git ref.
|
|
57
|
+
* Returns paths relative to projectPath.
|
|
58
|
+
*/
|
|
59
|
+
function getGitChangedFiles(projectPath, sinceRef) {
|
|
60
|
+
try {
|
|
61
|
+
const output = (0, node_child_process_1.execSync)(`git diff --name-only ${sinceRef} HEAD`, {
|
|
62
|
+
cwd: projectPath,
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
}).trim();
|
|
65
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get files staged in the current commit (for post-commit hook use).
|
|
73
|
+
*/
|
|
74
|
+
function getLastCommitFiles(projectPath) {
|
|
75
|
+
try {
|
|
76
|
+
const output = (0, node_child_process_1.execSync)("git diff-tree --no-commit-id -r --name-only HEAD", {
|
|
77
|
+
cwd: projectPath,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
}).trim();
|
|
80
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ─── AI bridge for sync (uses watch.ts bridges) ───────────────────────────────
|
|
87
|
+
function buildSyncPrompt(projectPath, changedFiles) {
|
|
88
|
+
const fileList = changedFiles.length > 0
|
|
89
|
+
? changedFiles.slice(0, 10).map(f => `- ${f}`).join("\n")
|
|
90
|
+
: "(full sync — no specific changed files)";
|
|
91
|
+
return [
|
|
92
|
+
`You are the DocuFlow wiki maintainer for: ${projectPath}`,
|
|
93
|
+
``,
|
|
94
|
+
`Files recently committed/changed:`,
|
|
95
|
+
fileList,
|
|
96
|
+
``,
|
|
97
|
+
`Use docuflow MCP tools in order:`,
|
|
98
|
+
`1. list_wiki({ project_path: "${projectPath}" }) — note page count`,
|
|
99
|
+
`2. ingest_source for each .docuflow/sources/ file relevant to the changes`,
|
|
100
|
+
` (if unsure, ingest all source files)`,
|
|
101
|
+
`3. update_index({ project_path: "${projectPath}" })`,
|
|
102
|
+
`4. lint_wiki({ project_path: "${projectPath}", check_type: "all" })`,
|
|
103
|
+
`5. Report: pages before/after, health score, high-severity issues.`,
|
|
104
|
+
``,
|
|
105
|
+
`Be concise. Just execute and report.`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Run Copilot CLI — directly calls DocuFlow MCP tools.
|
|
110
|
+
* Returns the assistant's text response or null.
|
|
111
|
+
*/
|
|
112
|
+
function runCopilotSync(prompt) {
|
|
113
|
+
const result = (0, node_child_process_1.spawnSync)("copilot", ["--prompt", prompt, "--allow-all-tools", "--allow-all-paths", "--no-ask-user", "--output-format", "json"], { encoding: "utf8", timeout: 180_000 });
|
|
114
|
+
if (result.error || result.status !== 0)
|
|
115
|
+
return null;
|
|
116
|
+
let lastMessage = null;
|
|
117
|
+
for (const line of (result.stdout ?? "").split("\n")) {
|
|
118
|
+
try {
|
|
119
|
+
const obj = JSON.parse(line.trim());
|
|
120
|
+
if (obj.type === "assistant.message" && obj.data?.content)
|
|
121
|
+
lastMessage = obj.data.content;
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
}
|
|
125
|
+
return lastMessage;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Run Claude CLI — directly calls DocuFlow MCP tools.
|
|
129
|
+
* Pass allowDangerousPermissions=true (via --allow-dangerous-permissions CLI flag)
|
|
130
|
+
* to skip interactive permission prompts for non-interactive use.
|
|
131
|
+
* Without it Claude CLI may prompt for tool access and time out in a daemon context.
|
|
132
|
+
* Injects docuflow MCP config explicitly via --mcp-config.
|
|
133
|
+
*/
|
|
134
|
+
function runClaudeSync(prompt, projectPath, allowDangerousPermissions = false) {
|
|
135
|
+
// Build the MCP config pointing to the local server binary
|
|
136
|
+
let serverBin;
|
|
137
|
+
try {
|
|
138
|
+
serverBin = require.resolve("@doquflow/server/dist/index.js");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
serverBin = node_path_1.default.resolve(__dirname, "../../server/dist/index.js");
|
|
142
|
+
}
|
|
143
|
+
const mcpConfig = JSON.stringify({
|
|
144
|
+
mcpServers: {
|
|
145
|
+
docuflow: { type: "stdio", command: process.execPath, args: [serverBin] }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const env = { ...process.env };
|
|
149
|
+
const claudeArgs = ["--print", "--mcp-config", mcpConfig];
|
|
150
|
+
if (allowDangerousPermissions) {
|
|
151
|
+
claudeArgs.splice(1, 0, "--dangerously-skip-permissions");
|
|
152
|
+
}
|
|
153
|
+
const result = (0, node_child_process_1.spawnSync)("claude", claudeArgs, { input: prompt, encoding: "utf8", timeout: 180_000, env });
|
|
154
|
+
const stdout = result.stdout?.trim() ?? "";
|
|
155
|
+
// Filter out Claude auth errors
|
|
156
|
+
if (!stdout || stdout.includes("Invalid API key") || stdout.includes("authentication"))
|
|
157
|
+
return null;
|
|
158
|
+
return stdout || null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Fallback for codex/api: generate markdown doc from changed files, then ingest.
|
|
162
|
+
*/
|
|
163
|
+
async function generateAndIngest(projectPath, changedFiles, bridge, info) {
|
|
164
|
+
const fileList = changedFiles.map(f => `- ${f}`).join("\n");
|
|
165
|
+
const prompt = [
|
|
166
|
+
`You are maintaining documentation for a software project.`,
|
|
167
|
+
`These files changed:\n${fileList}`,
|
|
168
|
+
``,
|
|
169
|
+
`Write a concise markdown document (200-500 words) capturing what changed.`,
|
|
170
|
+
`Markdown only. Start with # heading. No preamble.`,
|
|
171
|
+
].join("\n");
|
|
172
|
+
let docContent = null;
|
|
173
|
+
if (bridge === "codex") {
|
|
174
|
+
const r = (0, node_child_process_1.spawnSync)("codex", [prompt], { encoding: "utf8", timeout: 90_000 });
|
|
175
|
+
docContent = r.status === 0 ? r.stdout?.trim() ?? null : null;
|
|
176
|
+
}
|
|
177
|
+
else if (bridge === "api" && process.env.ANTHROPIC_API_KEY) {
|
|
178
|
+
const https = require("https");
|
|
179
|
+
const body = JSON.stringify({ model: "claude-3-5-haiku-20241022", max_tokens: 1024, messages: [{ role: "user", content: prompt }] });
|
|
180
|
+
docContent = await new Promise((resolve) => {
|
|
181
|
+
const req = https.request({ hostname: "api.anthropic.com", path: "/v1/messages", method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json", "x-api-key": process.env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "Content-Length": Buffer.byteLength(body) } }, (res) => { let d = ""; res.on("data", (ch) => d += ch); res.on("end", () => { try {
|
|
183
|
+
resolve(JSON.parse(d)?.content?.[0]?.text ?? null);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
resolve(null);
|
|
187
|
+
} }); });
|
|
188
|
+
req.on("error", () => resolve(null));
|
|
189
|
+
req.setTimeout(90_000, () => { req.destroy(); resolve(null); });
|
|
190
|
+
req.write(body);
|
|
191
|
+
req.end();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (!docContent) {
|
|
195
|
+
info(" ⚠ AI returned no content — skipping AI doc generation");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const sourcesDir = node_path_1.default.join(projectPath, ".docuflow", "sources");
|
|
199
|
+
await promises_1.default.mkdir(sourcesDir, { recursive: true });
|
|
200
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
201
|
+
const autoFilename = `auto_sync_${timestamp}.md`;
|
|
202
|
+
await promises_1.default.writeFile(node_path_1.default.join(sourcesDir, autoFilename), docContent, "utf8");
|
|
203
|
+
info(` ✅ AI doc saved → ${autoFilename}`);
|
|
204
|
+
}
|
|
205
|
+
async function run(options = {}) {
|
|
206
|
+
const projectPath = node_path_1.default.resolve(options.projectPath ?? process.cwd());
|
|
207
|
+
const docuDir = node_path_1.default.join(projectPath, ".docuflow");
|
|
208
|
+
const sourcesDir = node_path_1.default.join(docuDir, "sources");
|
|
209
|
+
const quiet = options.quiet ?? false;
|
|
210
|
+
const failThreshold = options.failOnScore ?? 70;
|
|
211
|
+
function info(msg) {
|
|
212
|
+
if (!quiet)
|
|
213
|
+
console.log(msg);
|
|
214
|
+
}
|
|
215
|
+
if (!node_fs_1.default.existsSync(docuDir)) {
|
|
216
|
+
console.error(c.red(` ✗ .docuflow/ not found at ${projectPath}`));
|
|
217
|
+
console.error(` Run "docuflow init" first.`);
|
|
218
|
+
process.exit(2);
|
|
219
|
+
}
|
|
220
|
+
info(c.bold("\n 🔄 DocuFlow Sync\n"));
|
|
221
|
+
const startTime = Date.now();
|
|
222
|
+
let skipManualSync = false; // set true when Copilot/Claude handle everything
|
|
223
|
+
// ── Step 1: Determine which source files to process ─────────────────────────
|
|
224
|
+
let sourceFilesToProcess = [];
|
|
225
|
+
if (options.sourceFile) {
|
|
226
|
+
// Single file mode
|
|
227
|
+
sourceFilesToProcess = [options.sourceFile];
|
|
228
|
+
info(` 📄 Single file mode: ${c.cyan(options.sourceFile)}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Scan all .md files in sources/
|
|
232
|
+
try {
|
|
233
|
+
const all = await promises_1.default.readdir(sourcesDir);
|
|
234
|
+
sourceFilesToProcess = all.filter((f) => f.endsWith(".md"));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
info(c.yellow(` ⚠ No sources/ directory found — nothing to ingest`));
|
|
238
|
+
sourceFilesToProcess = [];
|
|
239
|
+
}
|
|
240
|
+
info(` 📚 Found ${sourceFilesToProcess.length} source file(s) to ingest`);
|
|
241
|
+
}
|
|
242
|
+
// ── Step 2: AI-powered sync for code changes ─────────────────────────────
|
|
243
|
+
if (options.ai) {
|
|
244
|
+
const codeExts = /\.(ts|tsx|js|jsx|mjs|py|go|rb|java|cs|php|rs|kt|swift)$/;
|
|
245
|
+
const changedCodeFiles = options.sinceCommit
|
|
246
|
+
? getGitChangedFiles(projectPath, options.sinceCommit).filter(f => !f.startsWith(".docuflow") && codeExts.test(f))
|
|
247
|
+
: getLastCommitFiles(projectPath).filter(f => !f.startsWith(".docuflow") && codeExts.test(f));
|
|
248
|
+
const bridge = (0, watch_1.detectBridge)({ useAI: true, forceCopilot: options.forceCopilot, forceClaude: options.forceClaude, forceCodex: options.forceCodex });
|
|
249
|
+
const sinceLabel = options.sinceCommit ?? "last commit";
|
|
250
|
+
if (changedCodeFiles.length > 0) {
|
|
251
|
+
info(`\n 🤖 ${changedCodeFiles.length} code file(s) changed (${sinceLabel})`);
|
|
252
|
+
for (const f of changedCodeFiles.slice(0, 5))
|
|
253
|
+
info(c.dim(` ${f}`));
|
|
254
|
+
if (bridge === "copilot") {
|
|
255
|
+
info(` ⚡ Copilot will directly call DocuFlow MCP tools (ingest + index + lint)...`);
|
|
256
|
+
const prompt = buildSyncPrompt(projectPath, changedCodeFiles);
|
|
257
|
+
const result = runCopilotSync(prompt);
|
|
258
|
+
if (result) {
|
|
259
|
+
info(c.green(` ✅ Copilot completed wiki sync via MCP tools`));
|
|
260
|
+
info(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
261
|
+
// Copilot handled everything — skip manual steps below
|
|
262
|
+
skipManualSync = true;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
info(c.yellow(` ⚠ Copilot returned no result — continuing with direct sync`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (bridge === "claude") {
|
|
269
|
+
info(` ⚡ Claude will directly call DocuFlow MCP tools (ingest + index + lint)...`);
|
|
270
|
+
const prompt = buildSyncPrompt(projectPath, changedCodeFiles);
|
|
271
|
+
const result = runClaudeSync(prompt, projectPath, options.allowDangerousPermissions);
|
|
272
|
+
if (result) {
|
|
273
|
+
info(c.green(` ✅ Claude completed wiki sync via MCP tools`));
|
|
274
|
+
info(c.dim(` ${result.replace(/\n/g, "\n ")}`));
|
|
275
|
+
skipManualSync = true;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
info(c.yellow(` ⚠ Claude returned no result — continuing with direct sync`));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else if (bridge !== "none") {
|
|
282
|
+
// codex or api: generate doc → add to sources
|
|
283
|
+
await generateAndIngest(projectPath, changedCodeFiles, bridge, info);
|
|
284
|
+
const sourcesAgain = await promises_1.default.readdir(sourcesDir).catch(() => []);
|
|
285
|
+
sourceFilesToProcess = sourcesAgain.filter(f => f.endsWith(".md"));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
info(c.dim(` No code files changed since ${sinceLabel}`));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ── Step 3: Ingest + Step 4: Rebuild (skip if Copilot/Claude handled via MCP) ──
|
|
293
|
+
let totalPagesCreated = 0;
|
|
294
|
+
let ingestErrors = 0;
|
|
295
|
+
if (!skipManualSync) {
|
|
296
|
+
const { ingestSource } = loadServerTool("ingest-source");
|
|
297
|
+
const { updateIndex } = loadServerTool("update-index");
|
|
298
|
+
if (sourceFilesToProcess.length > 0) {
|
|
299
|
+
info(`\n 📥 Ingesting sources...`);
|
|
300
|
+
}
|
|
301
|
+
for (const filename of sourceFilesToProcess) {
|
|
302
|
+
try {
|
|
303
|
+
const result = await ingestSource({ project_path: projectPath, source_filename: filename });
|
|
304
|
+
const created = result.pages_created?.length ?? 0;
|
|
305
|
+
totalPagesCreated += created;
|
|
306
|
+
info(` ${created > 0 ? c.green("✓") : c.yellow("~")} ${filename} → ${created} page(s)`);
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
info(c.red(` ✗ ${filename}: ${e.message}`));
|
|
310
|
+
ingestErrors++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
info(`\n 📋 Rebuilding index...`);
|
|
314
|
+
const indexResult = await updateIndex({ project_path: projectPath });
|
|
315
|
+
info(c.green(` ✅ Index rebuilt — ${indexResult.entries_indexed} entries`));
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
info(c.dim(` ℹ️ Ingest/index handled by Copilot/Claude via MCP tools directly`));
|
|
319
|
+
}
|
|
320
|
+
// ── Step 5: Lint ─────────────────────────────────────────────────────────────
|
|
321
|
+
let healthScore = 100;
|
|
322
|
+
if (!options.noLint) {
|
|
323
|
+
info(`\n 🔍 Running wiki health check...`);
|
|
324
|
+
const { lintWiki } = loadServerTool("lint-wiki");
|
|
325
|
+
const lintResult = await lintWiki({ project_path: projectPath, check_type: "all" });
|
|
326
|
+
healthScore = lintResult.health_score ?? 100;
|
|
327
|
+
const scoreLabel = healthScore >= 90
|
|
328
|
+
? c.green(`${healthScore}/100`)
|
|
329
|
+
: healthScore >= 70
|
|
330
|
+
? c.yellow(`${healthScore}/100`)
|
|
331
|
+
: c.red(`${healthScore}/100`);
|
|
332
|
+
info(` 📊 Wiki health: ${scoreLabel}`);
|
|
333
|
+
if (lintResult.issues_found?.length > 0) {
|
|
334
|
+
const high = lintResult.issues_found.filter((i) => i.severity === "high").length;
|
|
335
|
+
const med = lintResult.issues_found.filter((i) => i.severity === "medium").length;
|
|
336
|
+
const low = lintResult.issues_found.filter((i) => i.severity === "low").length;
|
|
337
|
+
info(` 🔴 High: ${high} 🟡 Medium: ${med} 🟢 Low: ${low}`);
|
|
338
|
+
}
|
|
339
|
+
for (const rec of lintResult.recommendations?.slice(0, 3) ?? []) {
|
|
340
|
+
info(c.dim(` → ${rec}`));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// ── Summary ──────────────────────────────────────────────────────────────────
|
|
344
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
345
|
+
info(`\n ─────────────────────────────────────────────`);
|
|
346
|
+
info(` Sources processed: ${sourceFilesToProcess.length}`);
|
|
347
|
+
info(` Pages created: ${totalPagesCreated}`);
|
|
348
|
+
info(` Ingest errors: ${ingestErrors > 0 ? c.red(String(ingestErrors)) : "0"}`);
|
|
349
|
+
if (!options.noLint) {
|
|
350
|
+
info(` Health score: ${healthScore}/100`);
|
|
351
|
+
}
|
|
352
|
+
info(` Duration: ${elapsed}s`);
|
|
353
|
+
info(` ─────────────────────────────────────────────\n`);
|
|
354
|
+
// Exit with code 1 if health is below threshold (useful for CI)
|
|
355
|
+
if (!options.noLint && healthScore < failThreshold) {
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
if (ingestErrors > 0) {
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* docuflow watch stop — stop the running watch daemon for this project
|
|
4
|
+
* docuflow watch status — show whether the daemon is running + details
|
|
5
|
+
* docuflow watch restart — stop the current daemon then start a new one
|
|
6
|
+
*
|
|
7
|
+
* All three commands use the PID file at .docuflow/watch.pid written when
|
|
8
|
+
* the daemon starts. The PID file contains: pid, started_at, bridge, options.
|
|
9
|
+
*
|
|
10
|
+
* stop → SIGTERM → wait up to 5s → SIGKILL if still alive → remove PID file
|
|
11
|
+
* status → check PID alive, show uptime/bridge/project
|
|
12
|
+
* restart → stop + re-spawn `docuflow watch` with same options
|
|
13
|
+
*/
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.runStop = runStop;
|
|
19
|
+
exports.runStatus = runStatus;
|
|
20
|
+
exports.runRestart = runRestart;
|
|
21
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
22
|
+
const node_child_process_1 = require("node:child_process");
|
|
23
|
+
const watch_1 = require("./watch");
|
|
24
|
+
// ─── Colour helpers ─────────────────────────────────────────────────────────
|
|
25
|
+
const c = {
|
|
26
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
27
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
28
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
29
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
30
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
31
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
32
|
+
};
|
|
33
|
+
// ─── Uptime formatter ────────────────────────────────────────────────────────
|
|
34
|
+
function formatUptime(startedAt) {
|
|
35
|
+
const ms = Date.now() - new Date(startedAt).getTime();
|
|
36
|
+
const secs = Math.floor(ms / 1000);
|
|
37
|
+
const mins = Math.floor(secs / 60);
|
|
38
|
+
const hours = Math.floor(mins / 60);
|
|
39
|
+
const days = Math.floor(hours / 24);
|
|
40
|
+
if (days > 0)
|
|
41
|
+
return `${days}d ${hours % 24}h`;
|
|
42
|
+
if (hours > 0)
|
|
43
|
+
return `${hours}h ${mins % 60}m`;
|
|
44
|
+
if (mins > 0)
|
|
45
|
+
return `${mins}m ${secs % 60}s`;
|
|
46
|
+
return `${secs}s`;
|
|
47
|
+
}
|
|
48
|
+
// ─── stop ────────────────────────────────────────────────────────────────────
|
|
49
|
+
async function runStop(projectPath) {
|
|
50
|
+
projectPath = node_path_1.default.resolve(projectPath);
|
|
51
|
+
const data = await (0, watch_1.readPidFile)(projectPath);
|
|
52
|
+
if (!data) {
|
|
53
|
+
console.log(c.yellow(" ⚠ No watch.pid file found — daemon may not be running."));
|
|
54
|
+
console.log(c.dim(` (looked in ${(0, watch_1.getPidFilePath)(projectPath)})`));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
if (!(0, watch_1.isProcessAlive)(data.pid)) {
|
|
58
|
+
console.log(c.yellow(` ⚠ PID ${data.pid} is no longer alive (stale PID file).`));
|
|
59
|
+
await (0, watch_1.removePidFile)(projectPath);
|
|
60
|
+
console.log(c.dim(" Stale PID file removed."));
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
console.log(`\n 🛑 Stopping DocuFlow watch daemon`);
|
|
64
|
+
console.log(` PID: ${data.pid}`);
|
|
65
|
+
console.log(` Bridge: ${data.bridge}`);
|
|
66
|
+
console.log(` Uptime: ${formatUptime(data.started_at)}`);
|
|
67
|
+
console.log();
|
|
68
|
+
// Send SIGTERM — gives daemon chance to clean up gracefully
|
|
69
|
+
try {
|
|
70
|
+
process.kill(data.pid, "SIGTERM");
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
console.error(c.red(` ✗ Failed to send SIGTERM: ${e.message}`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
// Wait up to 5s for the process to exit, then SIGKILL
|
|
77
|
+
const deadline = Date.now() + 5000;
|
|
78
|
+
while ((0, watch_1.isProcessAlive)(data.pid)) {
|
|
79
|
+
if (Date.now() > deadline) {
|
|
80
|
+
console.log(c.yellow(" ⚠ Still alive after 5s — sending SIGKILL..."));
|
|
81
|
+
try {
|
|
82
|
+
process.kill(data.pid, "SIGKILL");
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
await new Promise(r => setTimeout(r, 500));
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
await new Promise(r => setTimeout(r, 200));
|
|
89
|
+
}
|
|
90
|
+
// Clean up PID file if daemon didn't remove it itself
|
|
91
|
+
await (0, watch_1.removePidFile)(projectPath);
|
|
92
|
+
if (!(0, watch_1.isProcessAlive)(data.pid)) {
|
|
93
|
+
console.log(c.green(" ✅ Watch daemon stopped successfully."));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(c.yellow(" ⚠ Process may still be running — check manually."));
|
|
97
|
+
}
|
|
98
|
+
console.log();
|
|
99
|
+
}
|
|
100
|
+
// ─── status ──────────────────────────────────────────────────────────────────
|
|
101
|
+
async function runStatus(projectPath) {
|
|
102
|
+
projectPath = node_path_1.default.resolve(projectPath);
|
|
103
|
+
const data = await (0, watch_1.readPidFile)(projectPath);
|
|
104
|
+
console.log(`\n 📡 DocuFlow Watch Status`);
|
|
105
|
+
console.log(" ─────────────────────────────────────────────────");
|
|
106
|
+
if (!data) {
|
|
107
|
+
console.log(` State: ${c.dim("stopped")} (no watch.pid file)`);
|
|
108
|
+
console.log(` Project: ${projectPath}`);
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(c.dim(` Run "docuflow watch" to start the daemon.`));
|
|
111
|
+
console.log();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
const alive = (0, watch_1.isProcessAlive)(data.pid);
|
|
115
|
+
if (!alive) {
|
|
116
|
+
console.log(` State: ${c.yellow("stopped")} (stale PID file — process died unexpectedly)`);
|
|
117
|
+
console.log(` Last PID: ${data.pid}`);
|
|
118
|
+
console.log(` Started: ${new Date(data.started_at).toLocaleString()}`);
|
|
119
|
+
console.log(` Bridge: ${data.bridge}`);
|
|
120
|
+
console.log();
|
|
121
|
+
console.log(c.dim(` Run "docuflow watch" to restart.`));
|
|
122
|
+
// Clean up stale PID file
|
|
123
|
+
await (0, watch_1.removePidFile)(projectPath);
|
|
124
|
+
console.log(c.dim(` (Stale PID file cleaned up)`));
|
|
125
|
+
console.log();
|
|
126
|
+
process.exit(1); // non-zero: daemon is not healthy
|
|
127
|
+
}
|
|
128
|
+
const bridgeIcon = data.bridge === "copilot" || data.bridge === "claude" ? "⚡" :
|
|
129
|
+
data.bridge === "codex" || data.bridge === "api" ? "🔤" : "📁";
|
|
130
|
+
const bridgeLabel = data.bridge === "none" ? c.dim("sources-only (no AI)") :
|
|
131
|
+
data.bridge === "copilot" ? c.green("copilot — direct MCP ⚡") :
|
|
132
|
+
data.bridge === "claude" ? c.green("claude — direct MCP ⚡") :
|
|
133
|
+
data.bridge === "codex" ? c.yellow("codex — doc-gen mode") :
|
|
134
|
+
data.bridge === "api" ? c.yellow("api — doc-gen mode") : data.bridge;
|
|
135
|
+
console.log(` State: ${c.green("● running")}`);
|
|
136
|
+
console.log(` PID: ${data.pid}`);
|
|
137
|
+
console.log(` Uptime: ${formatUptime(data.started_at)}`);
|
|
138
|
+
console.log(` Started: ${new Date(data.started_at).toLocaleString()}`);
|
|
139
|
+
console.log(` Bridge: ${bridgeLabel}`);
|
|
140
|
+
console.log(` Project: ${data.project_path}`);
|
|
141
|
+
if (data.options.lintIntervalHours) {
|
|
142
|
+
console.log(` Lint: every ${data.options.lintIntervalHours}h`);
|
|
143
|
+
}
|
|
144
|
+
if (data.options.codeExtensions?.length) {
|
|
145
|
+
console.log(` Exts: ${data.options.codeExtensions.join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
console.log();
|
|
148
|
+
console.log(c.dim(` Run "docuflow watch stop" to stop the daemon.`));
|
|
149
|
+
console.log(c.dim(` Run "docuflow watch restart" to restart with same options.`));
|
|
150
|
+
console.log();
|
|
151
|
+
}
|
|
152
|
+
// ─── restart ─────────────────────────────────────────────────────────────────
|
|
153
|
+
async function runRestart(projectPath) {
|
|
154
|
+
projectPath = node_path_1.default.resolve(projectPath);
|
|
155
|
+
const data = await (0, watch_1.readPidFile)(projectPath);
|
|
156
|
+
console.log(`\n 🔄 Restarting DocuFlow watch daemon\n`);
|
|
157
|
+
// Capture current options before stopping
|
|
158
|
+
const opts = data?.options ?? {
|
|
159
|
+
ai: false,
|
|
160
|
+
forceCopilot: false,
|
|
161
|
+
forceClaude: false,
|
|
162
|
+
forceCodex: false,
|
|
163
|
+
lintIntervalHours: 24,
|
|
164
|
+
};
|
|
165
|
+
// Stop existing daemon if running
|
|
166
|
+
if (data && (0, watch_1.isProcessAlive)(data.pid)) {
|
|
167
|
+
console.log(` Stopping PID ${data.pid} (bridge: ${data.bridge}, uptime: ${formatUptime(data.started_at)})...`);
|
|
168
|
+
try {
|
|
169
|
+
process.kill(data.pid, "SIGTERM");
|
|
170
|
+
}
|
|
171
|
+
catch { }
|
|
172
|
+
const deadline = Date.now() + 5000;
|
|
173
|
+
while ((0, watch_1.isProcessAlive)(data.pid) && Date.now() < deadline) {
|
|
174
|
+
await new Promise(r => setTimeout(r, 200));
|
|
175
|
+
}
|
|
176
|
+
if ((0, watch_1.isProcessAlive)(data.pid)) {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(data.pid, "SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
await (0, watch_1.removePidFile)(projectPath);
|
|
183
|
+
console.log(c.green(" ✅ Previous daemon stopped."));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(c.dim(" No running daemon found — starting fresh."));
|
|
187
|
+
if (data)
|
|
188
|
+
await (0, watch_1.removePidFile)(projectPath);
|
|
189
|
+
}
|
|
190
|
+
// Build args for new daemon
|
|
191
|
+
const cliArgs = ["watch"];
|
|
192
|
+
if (opts.ai)
|
|
193
|
+
cliArgs.push("--ai");
|
|
194
|
+
if (opts.forceCopilot)
|
|
195
|
+
cliArgs.push("--copilot");
|
|
196
|
+
if (opts.forceClaude)
|
|
197
|
+
cliArgs.push("--claude");
|
|
198
|
+
if (opts.forceCodex)
|
|
199
|
+
cliArgs.push("--codex");
|
|
200
|
+
if (opts.lintIntervalHours !== 24) {
|
|
201
|
+
cliArgs.push("--lint-interval", String(opts.lintIntervalHours));
|
|
202
|
+
}
|
|
203
|
+
if (opts.codeExtensions?.length) {
|
|
204
|
+
cliArgs.push("--code-ext", opts.codeExtensions.join(","));
|
|
205
|
+
}
|
|
206
|
+
// Resolve CLI entry point
|
|
207
|
+
const cliBin = require.resolve("./index")
|
|
208
|
+
.replace(/\.ts$/, ".js")
|
|
209
|
+
.replace("/src/", "/dist/");
|
|
210
|
+
console.log(`\n Spawning: node ${node_path_1.default.basename(cliBin)} ${cliArgs.join(" ")}`);
|
|
211
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [cliBin, ...cliArgs], {
|
|
212
|
+
cwd: projectPath,
|
|
213
|
+
detached: true,
|
|
214
|
+
stdio: "inherit",
|
|
215
|
+
});
|
|
216
|
+
child.unref(); // let parent exit, child runs independently
|
|
217
|
+
console.log(c.green(`\n ✅ New watch daemon spawned (PID will appear above).`));
|
|
218
|
+
console.log(c.dim(` Run "docuflow watch status" to confirm.`));
|
|
219
|
+
console.log();
|
|
220
|
+
// Give it a moment to write the PID file, then show status
|
|
221
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
222
|
+
await runStatus(projectPath);
|
|
223
|
+
}
|