diff --git a/src/cli/index.ts b/src/cli/index.ts index 3b1e14aa..a4b42cf5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -33,7 +33,7 @@ import { runFlushMemory } from "../commands/flush-memory.js"; import { maybeAutoBackfillMemory } from "../skillify/spawn-backfill-memory-worker.js"; import { confirm, detectPlatforms, allPlatformIds, log, promptLine, warn, type PlatformId } from "./util.js"; import { getVersion } from "./version.js"; -import { docsInstallLines, docsHintShown, markDocsHintShown, shouldPromptDocsSetup } from "../docs/install-hint.js"; +import { docsInstallLines, docsHintShown, markDocsHintShown } from "../docs/install-hint.js"; import { runUpdate } from "./update.js"; import { renderCliHelpBlock } from "./skillify-spec.js"; import { maybeAutoMineLocal } from "../skillify/spawn-mine-local-worker.js"; @@ -396,38 +396,55 @@ async function runInstallAll(args: string[]): Promise { log("Mining your past sessions for team memory in the background — sign in, then run `hivemind memory flush` to push."); } - // Docs onboarding. Inside a git repo (and able to prompt) run the SAME - // consent flow as `hivemind docs sync` — it asks "generate now?" then, on - // yes, "keep in sync on every commit?" (auto). Outside a repo, or when we - // can't prompt, fall back to the one-time informational hint. A docs hiccup - // must never break install, so the delegation is guarded. - const docsCwd = process.cwd(); - // Preflight (dynamic import + git probe) is guarded too: a failure here must - // not stop install — treat it as "not in a repo" so we fall back to the hint. - let inGitRepo = false; - try { - const { tryGitTopLevel } = await import("../graph/git-hook-install.js"); - inGitRepo = tryGitTopLevel(docsCwd) !== null; - } catch { /* probe unavailable → fall back to the hint */ } - const promptDocs = shouldPromptDocsSetup({ + // Docs onboarding at install — extracted to src/docs/install-docs.ts so the + // decision + detached-worker spawn are unit-tested. Everything effectful is + // injected here; a docs hiccup must never break install (guarded inside). + const { homedir } = await import("node:os"); + const { tryGitTopLevel } = await import("../graph/git-hook-install.js"); + const { loadConfig } = await import("../config.js"); + const { spawnDetachedNodeWorker } = await import("../utils/spawn-detached.js"); + const { isAutoEnabled } = await import("../docs/auto-registry.js"); + const { deriveProjectKey } = await import("../utils/repo-identity.js"); + const { runInstallDocsOnboarding } = await import("../docs/install-docs.js"); + await runInstallDocsOnboarding({ + cwd: process.cwd(), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), - inGitRepo, loggedIn: isLoggedIn(), + home: homedir(), + gitTopLevel: (cwd) => tryGitTopLevel(cwd), + loadCfg: () => loadConfig(), + autoEnabled: (orgId, root) => isAutoEnabled(orgId, deriveProjectKey(root).key), + // Build inline (fast, no LLM) so the wiki worker has a snapshot + the + // page estimate is real. Heavy graph deps stay lazy. + buildGraph: async (root) => { + const { runBuildCommand } = await import("../commands/graph.js"); + await runBuildCommand(["--cwd", root, "--trigger", "manual"]); + }, + onboard: async ({ root, orgId, orgName }) => { + const { runDocsOnboarding } = await import("../docs/onboarding.js"); + const { deriveProjectKey } = await import("../utils/repo-identity.js"); + const { loadCurrentSnapshot } = await import("../graph/load-current.js"); + return runDocsOnboarding({ + root, isGitRepo: true, orgId, orgName, + project: deriveProjectKey(root).key, + snap: loadCurrentSnapshot(root), + }); + }, + spawn: (workerArgs) => { + const cliEntry = process.argv[1]; + if (!cliEntry) return false; + spawnDetachedNodeWorker(cliEntry, workerArgs); + return true; + }, + showHint: () => { + if (docsHintShown()) return; + log(""); + for (const line of docsInstallLines()) log(line); + markDocsHintShown(); + }, + log, + warn, }); - if (promptDocs) { - log(""); - log("Docs (optional): set up documentation for this repository."); - try { - const { runDocsCommand } = await import("../commands/docs.js"); - await runDocsCommand(["sync", "--cwd", docsCwd]); - } catch (err) { - warn(`docs setup skipped: ${err instanceof Error ? err.message : String(err)}`); - } - } else if (!docsHintShown()) { - log(""); - for (const line of docsInstallLines()) log(line); - markDocsHintShown(); - } log(""); log("Done. Restart each assistant to activate hooks."); diff --git a/src/docs/install-docs.ts b/src/docs/install-docs.ts new file mode 100644 index 00000000..5e60f8e2 --- /dev/null +++ b/src/docs/install-docs.ts @@ -0,0 +1,105 @@ +/** + * The docs-onboarding step of `hivemind install`, factored out of + * `runInstallAll` so it can be tested deterministically (no pty, no network). + * + * Behaviour, all decided here: + * - Resolve the git root; if we can't prompt (no TTY / not signed in / not a + * repo) OR the root is the user's $HOME, fall back to the one-time hint. + * - Otherwise: build the graph INLINE (fast, no LLM), run the onboarding + * (generate? → agent? → auto?), and on consent spawn `docs wiki` DETACHED + * so the LLM generation never blocks install — mirroring `graph init`. + * - A docs hiccup must never break install: the effectful section is guarded + * and returns "noop" on failure. + * + * Everything effectful is injected, so a test asserts the exact decision + + * the exact worker spawn without touching git, the graph, or the backend. + */ + +import { isHomeRoot, shouldPromptDocsSetup } from "./install-hint.js"; +import type { OnboardingResult } from "./onboarding.js"; + +export interface InstallDocsDeps { + cwd: string; + interactive: boolean; + loggedIn: boolean; + home: string; + /** git toplevel for cwd, or null when not a repo. May throw → treated as null. */ + gitTopLevel: (cwd: string) => string | null; + /** Org config, or null when unavailable. */ + loadCfg: () => { orgId: string; orgName?: string } | null; + /** Is auto docs-sync already enabled for (org, repo)? Then don't re-prompt. */ + autoEnabled: (orgId: string, root: string) => boolean; + /** Build the code graph inline (fast, no LLM). */ + buildGraph: (root: string) => Promise; + /** Run the interactive consent flow. */ + onboard: (a: { root: string; orgId: string; orgName?: string }) => Promise; + /** Spawn a detached CLI worker (e.g. ["docs","wiki","--cwd",root]). False = no CLI entry. */ + spawn: (args: string[]) => boolean; + /** Print the one-time informational hint (sentinel-gated by the caller). */ + showHint: () => void; + log: (m: string) => void; + warn: (m: string) => void; +} + +export type InstallDocsAction = + | { kind: "hint" } // couldn't/needn't prompt → hint shown + | { kind: "already-enabled"; root: string } // auto already on → no re-prompt + | { kind: "declined" } // prompted, user said no to generate + | { kind: "spawned"; root: string } // consented → wiki spawned DETACHED + | { kind: "no-entry"; root: string } // consented but no CLI entry to spawn + | { kind: "noop" }; // no cfg, or a guarded failure + +export async function runInstallDocsOnboarding(d: InstallDocsDeps): Promise { + let inGitRepo = false; + let repoRoot = d.cwd; + try { + const top = d.gitTopLevel(d.cwd); + inGitRepo = top !== null; + repoRoot = top ?? d.cwd; + } catch { + /* probe unavailable → treat as not-a-repo, fall through to the hint */ + } + + const prompt = shouldPromptDocsSetup({ + interactive: d.interactive, + inGitRepo, + loggedIn: d.loggedIn, + atHome: isHomeRoot(repoRoot, d.home), + }); + if (!prompt) { + d.showHint(); + return { kind: "hint" }; + } + + try { + const cfg = d.loadCfg(); + if (!cfg) return { kind: "noop" }; + // Already set up: don't re-ASK (nothing new to consent to). Still build the + // graph — its post-build auto-refresh regenerates in the background, so an + // auto-enabled-but-empty corpus still gets filled (idempotent when full). + if (d.autoEnabled(cfg.orgId, repoRoot)) { + d.log(""); + await d.buildGraph(repoRoot); + d.log("Docs auto-sync is on for this repo — refreshing in the background. See: hivemind docs list"); + return { kind: "already-enabled", root: repoRoot }; + } + d.log(""); + d.log("Docs (optional): set up documentation for this repository."); + await d.buildGraph(repoRoot); + const result = await d.onboard({ root: repoRoot, orgId: cfg.orgId, orgName: cfg.orgName }); + if (!result.generate) return { kind: "declined" }; + // Only the wiki here — same as `hivemind graph init`. Per-file docs are a + // separate, heavy `docs generate` (every file × LLM); auto-sync generates + // them later on commit. `docs refresh` would NOT create missing per-file + // docs (it only refreshes drifted existing rows), so spawning it is a no-op. + if (d.spawn(["docs", "wiki", "--cwd", repoRoot])) { + d.log("Generating wiki docs in the background — check with: hivemind docs list"); + return { kind: "spawned", root: repoRoot }; + } + d.log("Run `hivemind docs wiki` to generate the corpus."); + return { kind: "no-entry", root: repoRoot }; + } catch (err) { + d.warn(`docs setup skipped: ${err instanceof Error ? err.message : String(err)}`); + return { kind: "noop" }; + } +} diff --git a/src/docs/install-hint.ts b/src/docs/install-hint.ts index dc03aa4c..61831312 100644 --- a/src/docs/install-hint.ts +++ b/src/docs/install-hint.ts @@ -14,7 +14,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; /** Lines describing docs enable/disable, for the install summary. */ export function docsInstallLines(): string[] { @@ -29,16 +29,32 @@ export function docsInstallLines(): string[] { /** * Should `hivemind install` actively PROMPT the docs consent flow (the same * one `hivemind docs sync` runs) instead of just printing the hint? Only when - * we can ask a human (TTY), we are inside a git repo (docs are per-repo), and - * we are signed in (the consent writes to the org registry). Otherwise the - * caller falls back to the one-time informational hint. + * we can ask a human (TTY), we are inside a git repo (docs are per-repo), we + * are signed in (the consent writes to the org registry), and the resolved git + * root is NOT the user's home directory. The home guard matters because a + * dotfiles repo makes `git rev-parse --show-toplevel` resolve to `~` from any + * subdirectory — offering to document the whole home is never what the user + * wants. Otherwise the caller falls back to the one-time informational hint. */ +/** + * Is the resolved git root the user's home directory? Compared through + * `path.resolve` on BOTH sides so it holds cross-platform: Git's + * `--show-toplevel` yields forward slashes even on Windows (`C:/Users/x`) + * while `os.homedir()` yields native separators (`C:\Users\x`) — a raw `===` + * would miss the match and let the home guard through. + */ +export function isHomeRoot(gitRoot: string, home: string): boolean { + return resolve(gitRoot) === resolve(home); +} + export function shouldPromptDocsSetup(opts: { interactive: boolean; inGitRepo: boolean; loggedIn: boolean; + /** True when the resolved git root is the user's $HOME (dotfiles repo). */ + atHome?: boolean; }): boolean { - return opts.interactive && opts.inGitRepo && opts.loggedIn; + return opts.interactive && opts.inGitRepo && opts.loggedIn && !opts.atHome; } /** Sentinel marking that the install docs hint has been shown once. */ diff --git a/tests/shared/docs-install-hint.test.ts b/tests/shared/docs-install-hint.test.ts index 536761d4..e20a57c5 100644 --- a/tests/shared/docs-install-hint.test.ts +++ b/tests/shared/docs-install-hint.test.ts @@ -7,6 +7,7 @@ import { docsInstallLines, markDocsHintShown, shouldPromptDocsSetup, + isHomeRoot, } from "../../src/docs/install-hint.js"; describe("docsInstallLines (install-time docs onboarding)", () => { @@ -37,6 +38,23 @@ describe("shouldPromptDocsSetup (ask in-repo, else fall back to the hint)", () = expect(shouldPromptDocsSetup({ interactive: true, inGitRepo: false, loggedIn: true })).toBe(false); expect(shouldPromptDocsSetup({ interactive: true, inGitRepo: true, loggedIn: false })).toBe(false); }); + + it("does not prompt when the git root is the user's home (dotfiles repo)", () => { + expect(shouldPromptDocsSetup({ interactive: true, inGitRepo: true, loggedIn: true, atHome: true })).toBe(false); + // atHome omitted/false keeps the normal behavior. + expect(shouldPromptDocsSetup({ interactive: true, inGitRepo: true, loggedIn: true, atHome: false })).toBe(true); + }); +}); + +describe("isHomeRoot (cross-platform home comparison)", () => { + it("matches equivalent paths through path.resolve (trailing slash, .., etc.)", () => { + expect(isHomeRoot("/home/x", "/home/x")).toBe(true); + expect(isHomeRoot("/home/x/", "/home/x")).toBe(true); + expect(isHomeRoot("/home/y/../x", "/home/x")).toBe(true); + }); + it("does not match a subdirectory of home", () => { + expect(isHomeRoot("/home/x/project", "/home/x")).toBe(false); + }); }); describe("first-install sentinel (show the hint once)", () => { diff --git a/tests/shared/install-docs.test.ts b/tests/shared/install-docs.test.ts new file mode 100644 index 00000000..1c0d6646 --- /dev/null +++ b/tests/shared/install-docs.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import { runInstallDocsOnboarding, type InstallDocsDeps } from "../../src/docs/install-docs.js"; +import type { OnboardingResult } from "../../src/docs/onboarding.js"; + +// A fully-injected, in-memory harness — no git, no graph, no network, no pty. +// Every effect is a spy so we assert the EXACT decision and worker spawn. +function harness(over: Partial = {}) { + const calls: string[] = []; + const spawned: string[][] = []; + const onboardResult: { value: OnboardingResult } = { value: { generate: true, auto: false, asked: true } }; + const deps: InstallDocsDeps = { + cwd: "/work/repo", + interactive: true, + loggedIn: true, + home: "/home/user", + gitTopLevel: (cwd) => cwd === "/nope" ? null : "/work/repo", + loadCfg: () => ({ orgId: "org-1", orgName: "Acme" }), + autoEnabled: () => false, + buildGraph: async () => { calls.push("build"); }, + onboard: async () => { calls.push("onboard"); return onboardResult.value; }, + spawn: (args) => { calls.push("spawn"); spawned.push(args); return true; }, + showHint: () => { calls.push("hint"); }, + log: () => {}, + warn: () => {}, + ...over, + }; + return { deps, calls, spawned, onboardResult }; +} + +describe("runInstallDocsOnboarding — install docs decision + detached spawn", () => { + it("consent → builds graph THEN spawns the detached wiki worker with the git root", async () => { + const h = harness(); + const action = await runInstallDocsOnboarding(h.deps); + + expect(action).toEqual({ kind: "spawned", root: "/work/repo" }); + // Order matters: the wiki worker needs the freshly-built snapshot. + expect(h.calls).toEqual(["build", "onboard", "spawn"]); + // Spawns the DETACHED wiki worker (async), not inline sync — same as graph init. + expect(h.spawned).toEqual([["docs", "wiki", "--cwd", "/work/repo"]]); + expect(h.calls).not.toContain("hint"); + }); + + it("auto already enabled → builds (its refresh covers an empty corpus), never re-prompts or double-spawns", async () => { + const h = harness({ autoEnabled: () => true }); + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "already-enabled", root: "/work/repo" }); + // Build runs (its post-build auto-refresh regenerates) but we never ask + // again or spawn the onboarding workers ourselves. + expect(h.calls).toEqual(["build"]); + expect(h.calls).not.toContain("onboard"); + expect(h.spawned).toEqual([]); + }); + + it("decline generate → no graph-blocking generation, nothing spawned", async () => { + const h = harness(); + h.onboardResult.value = { generate: false, auto: false, asked: true }; + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "declined" }); + expect(h.calls).toEqual(["build", "onboard"]); // asked, but never spawned + expect(h.spawned).toEqual([]); + }); + + it("home-repo guard: git root === $HOME → hint, never prompts/builds/spawns", async () => { + const h = harness({ gitTopLevel: () => "/home/user" }); // dotfiles repo + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "hint" }); + expect(h.calls).toEqual(["hint"]); + expect(h.spawned).toEqual([]); + }); + + // (Windows slash-equivalence of the home guard is a property of isHomeRoot's + // path.resolve normalization; it can't be asserted meaningfully on a POSIX + // runner, so it's not faked here — see isHomeRoot's own unit test.) + + it("not a git repo → hint, nothing spawned", async () => { + const h = harness({ cwd: "/nope" }); + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "hint" }); + expect(h.spawned).toEqual([]); + }); + + it("not signed in → hint", async () => { + const h = harness({ loggedIn: false }); + expect((await runInstallDocsOnboarding(h.deps)).kind).toBe("hint"); + expect(h.spawned).toEqual([]); + }); + + it("non-interactive (no TTY) → hint, never asks", async () => { + const h = harness({ interactive: false }); + expect((await runInstallDocsOnboarding(h.deps)).kind).toBe("hint"); + expect(h.calls).not.toContain("onboard"); + }); + + it("consent but no CLI entry to spawn → no-entry, no worker spawned", async () => { + const h = harness({ spawn: () => false }); + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "no-entry", root: "/work/repo" }); + expect(h.spawned).toEqual([]); + }); + + it("no org config → noop (doesn't build or spawn)", async () => { + const h = harness({ loadCfg: () => null }); + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "noop" }); + expect(h.calls).not.toContain("build"); + }); + + it("a build/onboarding failure never breaks install — guarded → noop", async () => { + const warn = vi.fn(); + const h = harness({ buildGraph: async () => { throw new Error("graph boom"); }, warn }); + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "noop" }); + expect(warn).toHaveBeenCalledWith("docs setup skipped: graph boom"); + expect(h.spawned).toEqual([]); + }); + + it("a throwing git probe is treated as not-a-repo → hint", async () => { + const h = harness({ gitTopLevel: () => { throw new Error("git missing"); } }); + const action = await runInstallDocsOnboarding(h.deps); + expect(action).toEqual({ kind: "hint" }); + }); +});