Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 47 additions & 30 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -396,38 +396,55 @@ async function runInstallAll(args: string[]): Promise<void> {
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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.");
Expand Down
105 changes: 105 additions & 0 deletions src/docs/install-docs.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<void>;
/** Run the interactive consent flow. */
onboard: (a: { root: string; orgId: string; orgName?: string }) => Promise<OnboardingResult>;
/** 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<InstallDocsAction> {
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" };
}
}
26 changes: 21 additions & 5 deletions src/docs/install-hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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. */
Expand Down
18 changes: 18 additions & 0 deletions tests/shared/docs-install-hint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
docsInstallLines,
markDocsHintShown,
shouldPromptDocsSetup,
isHomeRoot,
} from "../../src/docs/install-hint.js";

describe("docsInstallLines (install-time docs onboarding)", () => {
Expand Down Expand Up @@ -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)", () => {
Expand Down
Loading