feat(FN-2361): codify workspace verification bootstrap contract

- Add root verify:workspace script enforcing lint -> test -> build order
- Update CI workflow to run verify:workspace as the single workspace gate before binary packaging
- Add CLI guardrail tests for workflow sequencing and root script contract invariants
- Document deterministic workspace bootstrap expectations in contributing guide
- Clarify bundle-output test bootstrap intent for explicit artifact setup
This commit is contained in:
Fusion
2026-04-23 15:44:54 -07:00
committed by gsxdsm
parent 54843012bc
commit fbe5b82ce0
12 changed files with 448 additions and 26 deletions

View File

@@ -14,6 +14,9 @@ const tsupConfigPath = join(cliRoot, "tsup.config.ts");
describe("CLI bundle output", () => {
beforeAll(() => {
// Intentional: bundle-output tests validate compiled artifacts, so they
// perform their own explicit build bootstrap instead of relying on ambient
// workspace dist/ state.
buildCliWithRealDashboardAssets();
}, 300_000);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync, accessSync, constants, existsSync } from "node:fs";
import { readFileSync, accessSync, constants } from "node:fs";
import { join } from "node:path";
import { parse } from "yaml";
@@ -22,13 +22,20 @@ function loadWorkflow(name: string): any {
describe("CI workflow (.github/workflows/ci.yml)", () => {
let workflow: any;
let content: string;
let ciSteps: any[];
beforeAll(() => {
const result = loadWorkflow("ci.yml");
workflow = result.parsed;
content = result.content;
ciSteps = workflow.jobs?.ci?.steps ?? [];
});
const findStepByRun = (runSnippet: string) => ciSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
const findStepIndexByRun = (runSnippet: string) =>
ciSteps.findIndex((step) => typeof step.run === "string" && step.run.includes(runSnippet));
it("is valid YAML", () => {
expect(workflow).toBeDefined();
expect(typeof workflow).toBe("object");
@@ -47,8 +54,24 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
expect(content).toContain("pnpm install");
});
it("includes pnpm build step", () => {
expect(content).toContain("pnpm build");
it("uses verify:workspace as the single lint/test/build contract", () => {
const verifyStep = findStepByRun("pnpm verify:workspace");
expect(verifyStep).toBeDefined();
expect(verifyStep.name).toContain("bootstrap contract");
const directLintStep = findStepByRun("pnpm lint");
const directTestStep = findStepByRun("pnpm test");
const directBuildStep = findStepByRun("pnpm build");
expect(directLintStep).toBeUndefined();
expect(directTestStep).toBeUndefined();
expect(directBuildStep).toBeUndefined();
});
it("runs workspace verification before binary packaging", () => {
const verifyIdx = findStepIndexByRun("pnpm verify:workspace");
const buildExeIdx = findStepIndexByRun("build:exe");
expect(verifyIdx).toBeGreaterThanOrEqual(0);
expect(buildExeIdx).toBeGreaterThan(verifyIdx);
});
it("includes binary build step", () => {
@@ -62,10 +85,6 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
it("verifies binary exists after build", () => {
expect(content).toContain("test -f packages/cli/dist/fn");
});
it("includes pnpm test step", () => {
expect(content).toContain("pnpm test");
});
});
describe("Version & Release workflow (.github/workflows/version.yml)", () => {

View File

@@ -16,6 +16,11 @@ function loadWorkflowYaml(name: string): any {
return parse(content);
}
function loadRootPackageJson(): any {
const path = join(workspaceRoot, "package.json");
return JSON.parse(readFileSync(path, "utf-8"));
}
describe("CLI package.json publishing config", () => {
const pkg = loadPackageJson("cli");
@@ -106,6 +111,30 @@ describe("Scoped @fusion/* packages publishing config", () => {
}
});
describe("Workspace bootstrap script contract", () => {
const rootPkg = loadRootPackageJson();
it("keeps root test self-sufficient (no implicit pre-build dependency)", () => {
const testScript = rootPkg.scripts?.test;
expect(testScript).toBeDefined();
expect(testScript).toContain("pnpm -r");
expect(testScript).not.toContain("pnpm build");
});
it("defines verify:workspace in lint -> test -> build order", () => {
const verifyScript = rootPkg.scripts?.["verify:workspace"];
expect(verifyScript).toBe("pnpm lint && pnpm test && pnpm build");
const lintIdx = verifyScript.indexOf("pnpm lint");
const testIdx = verifyScript.indexOf("pnpm test");
const buildIdx = verifyScript.indexOf("pnpm build");
expect(lintIdx).toBeGreaterThanOrEqual(0);
expect(testIdx).toBeGreaterThan(lintIdx);
expect(buildIdx).toBeGreaterThan(testIdx);
});
});
describe("Workflow YAML validity", () => {
it("ci.yml is valid YAML", () => {
const parsed = loadWorkflowYaml("ci.yml");

View File

@@ -0,0 +1,108 @@
/**
* Thin wrapper around claude-skills install logic that also handles the
* "should we even try?" question (reads global settings, checks detection)
* so call sites don't each repeat that plumbing.
*/
import { getPackageManagerAgentDir } from "./auth-paths.js";
import {
ensureFusionSkillForProjects,
installFusionSkillIntoProject,
isPiClaudeCliConfigured,
resolveFusionSkillSource,
type InstallResult,
} from "./claude-skills.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
/**
* Resolve whether pi-claude-cli is configured by reading the user's global
* settings (`~/.fusion/agent/settings.json` with cascade to legacy `.pi`).
*
* The project path is used only so the settings reader can merge the
* project's `.fusion/settings.json` overlay; we only examine the global
* portion for this check.
*/
export function detectPiClaudeCli(projectPath: string): boolean {
try {
const agentDir = getPackageManagerAgentDir();
const view = createReadOnlyProviderSettingsView(projectPath, agentDir);
return isPiClaudeCliConfigured(view.getGlobalSettings());
} catch {
return false;
}
}
/**
* Install the fusion skill into a single newly-created project, logging the
* outcome to the console. Intended for CLI entry points (`fn init`,
* `fn project add`) where the user is watching the output.
*
* No-op (and silent) when pi-claude-cli is not configured so the file layout
* stays clean for users who only use direct Anthropic API.
*/
export function maybeInstallClaudeSkillForNewProject(projectPath: string): InstallResult {
const enabled = detectPiClaudeCli(projectPath);
const result = installFusionSkillIntoProject(projectPath, { enabled });
logInstallResult(result, { verbose: enabled });
return result;
}
/**
* Install the fusion skill into every registered project during server
* startup. Non-blocking: callers invoke this without awaiting. Logs one line
* per non-skipped, non-already-installed project; stays quiet when there's
* nothing to do.
*/
export function ensureClaudeSkillsForAllProjectsOnStartup(
projects: Array<{ id: string; name: string; path: string }>,
): InstallResult[] {
if (projects.length === 0) return [];
// Detect using the first project; all share the same user-level settings.
const enabled = detectPiClaudeCli(projects[0]!.path);
if (!enabled) {
return projects.map((p) => ({
outcome: "skipped" as const,
target: `${p.path}/.claude/skills/fusion`,
reason: "pi-claude-cli not configured",
}));
}
const source = resolveFusionSkillSource();
const results = ensureFusionSkillForProjects(projects, { enabled, source });
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
if (result.outcome === "installed" || result.outcome === "replaced") {
console.log(
`[fusion] Installed Claude skill for project '${projects[i]!.name}' (${result.outcome}): ${result.target}`,
);
} else if (result.outcome === "failed") {
console.warn(
`[fusion] Could not install Claude skill for project '${projects[i]!.name}': ${result.reason ?? "unknown error"}`,
);
}
}
return results;
}
function logInstallResult(result: InstallResult, options: { verbose: boolean }): void {
switch (result.outcome) {
case "installed":
console.log(` ✓ Installed fusion skill at ${result.target}`);
break;
case "replaced":
console.log(` ✓ Refreshed fusion skill at ${result.target}`);
break;
case "already-installed":
if (options.verbose) {
console.log(` ✓ Fusion skill already present at ${result.target}`);
}
break;
case "failed":
console.warn(
` ⚠ Could not install fusion skill: ${result.reason ?? "unknown error"}`,
);
break;
case "skipped":
// Silent — the user hasn't opted into Claude Code routing.
break;
}
}

View File

@@ -0,0 +1,250 @@
/**
* Claude Code skill installation for Fusion projects.
*
* When pi-claude-cli routes the model through Claude Code, pi's own skill
* injection is bypassed (pi-claude-cli only forwards systemPrompt + AGENTS.md).
* Claude Code instead auto-loads skills from `<project>/.claude/skills/<name>/`
* and `~/.claude/skills/<name>/`. To make the fusion skill available inside
* Claude Code sessions, we symlink the shipped `skill/fusion` directory into
* each project's `.claude/skills/fusion/`.
*
* Entry points that call installFusionSkillIntoProject:
* - `fn init` (packages/cli/src/commands/init.ts)
* - `fn project add` (packages/cli/src/commands/project.ts)
* - POST /api/projects (packages/dashboard/src/routes.ts)
* - serve.ts startup reconciliation (packages/cli/src/commands/serve.ts)
*
* All call sites are guarded by isPiClaudeCliConfigured() so users who don't
* route through Claude Code never see `.claude/skills/` appear in their repos.
*/
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readlinkSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* The skill directory name under `.claude/skills/`. The shipped source at
* `packages/cli/skill/fusion/` is symlinked in at this path.
*/
export const FUSION_SKILL_NAME = "fusion";
/**
* Result of an install attempt.
*
* - "installed": created a new symlink (or copy fallback)
* - "already-installed": correct link/copy already present, nothing to do
* - "skipped": pi-claude-cli not configured, intentionally did nothing
* - "replaced": an existing incorrect entry was replaced
* - "failed": error occurred; reason populated
*/
export type InstallOutcome =
| "installed"
| "already-installed"
| "skipped"
| "replaced"
| "failed";
export interface InstallResult {
outcome: InstallOutcome;
target: string;
source?: string;
reason?: string;
}
/**
* Check whether the user has pi-claude-cli configured, meaning pi should route
* model calls through Claude Code and we should mirror the fusion skill into
* each project's `.claude/skills/`.
*
* Detection order:
* 1. Explicit `useClaudeCli` boolean in global settings (either value wins)
* 2. Legacy signal: `packages` array contains `"npm:pi-claude-cli"`
*/
export function isPiClaudeCliConfigured(
globalSettings: Record<string, unknown> | null | undefined,
): boolean {
if (!globalSettings || typeof globalSettings !== "object") {
return false;
}
const toggle = (globalSettings as { useClaudeCli?: unknown }).useClaudeCli;
if (typeof toggle === "boolean") {
return toggle;
}
const packages = (globalSettings as { packages?: unknown }).packages;
if (Array.isArray(packages)) {
return packages.some(
(entry) =>
typeof entry === "string" &&
/(^|[:/])pi-claude-cli(@|$)/.test(entry.trim()),
);
}
return false;
}
/**
* Resolve the path to the shipped fusion skill directory.
*
* At runtime this file lives at `<cli-pkg>/dist/commands/claude-skills.js`
* when published, and `<cli-pkg>/src/commands/claude-skills.ts` in dev under
* tsx. Either way, `../../skill/fusion` points at the packaged skill.
*
* Returns null if the directory is missing (e.g. broken install).
*/
export function resolveFusionSkillSource(): string | null {
const here = fileURLToPath(import.meta.url);
const candidate = resolve(dirname(here), "..", "..", "skill", FUSION_SKILL_NAME);
return existsSync(candidate) ? candidate : null;
}
/**
* Install the fusion skill into `<projectPath>/.claude/skills/fusion`.
*
* Idempotent:
* - If the target is already a symlink to the current source, no-op.
* - If it's a stale symlink or a foreign file/dir, it's replaced.
* - Prefers symlinks so skill updates flow automatically when the fusion
* package is upgraded; falls back to a copy on platforms where symlinks
* aren't allowed (typically Windows without developer mode).
*
* Never throws: errors are captured and returned as {outcome: "failed", reason}.
*/
export function installFusionSkillIntoProject(
projectPath: string,
options: { source?: string | null; enabled?: boolean } = {},
): InstallResult {
const target = join(projectPath, ".claude", "skills", FUSION_SKILL_NAME);
if (options.enabled === false) {
return { outcome: "skipped", target, reason: "pi-claude-cli not configured" };
}
const source = options.source ?? resolveFusionSkillSource();
if (!source) {
return {
outcome: "failed",
target,
reason: "fusion skill source directory not found in installed package",
};
}
try {
mkdirSync(dirname(target), { recursive: true });
let replaced = false;
if (existsSync(target) || isBrokenSymlink(target)) {
const stat = lstatSync(target);
if (stat.isSymbolicLink()) {
const current = safeReadlink(target);
if (current && resolve(dirname(target), current) === resolve(source)) {
return { outcome: "already-installed", target, source };
}
unlinkSync(target);
replaced = true;
} else {
// A directory or file occupies the slot — don't blow it away unless
// it looks like something we created. Check for a SKILL.md to reduce
// the odds of clobbering a user's hand-authored skill.
const skillMd = join(target, "SKILL.md");
if (!existsSync(skillMd)) {
return {
outcome: "failed",
target,
reason: "target exists and does not look like a fusion skill install",
};
}
// Replace: a plain copy from a prior install. Delete and re-symlink.
removeRecursive(target);
replaced = true;
}
}
try {
symlinkSync(source, target, "dir");
} catch (err) {
// Windows / restricted FS — copy instead.
const reason = err instanceof Error ? err.message : String(err);
try {
cpSync(source, target, { recursive: true });
return {
outcome: replaced ? "replaced" : "installed",
target,
source,
reason: `symlink failed (${reason}); copied files instead`,
};
} catch (copyErr) {
return {
outcome: "failed",
target,
source,
reason: copyErr instanceof Error ? copyErr.message : String(copyErr),
};
}
}
return { outcome: replaced ? "replaced" : "installed", target, source };
} catch (err) {
return {
outcome: "failed",
target,
source,
reason: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Install the fusion skill into every project in a list. Used at server
* startup to self-heal any projects whose `.claude/skills/fusion` was removed
* or never installed (e.g. projects registered before this feature landed).
*
* Failures are collected but never thrown — startup must not be blocked by
* filesystem quirks on a single project.
*/
export function ensureFusionSkillForProjects(
projects: Array<{ id: string; name: string; path: string }>,
options: { enabled: boolean; source?: string | null } = { enabled: false },
): InstallResult[] {
if (!options.enabled) {
return projects.map((p) => ({
outcome: "skipped" as const,
target: join(p.path, ".claude", "skills", FUSION_SKILL_NAME),
reason: "pi-claude-cli not configured",
}));
}
const source = options.source ?? resolveFusionSkillSource();
return projects.map((p) =>
installFusionSkillIntoProject(p.path, { source, enabled: true }),
);
}
function isBrokenSymlink(path: string): boolean {
try {
const stat = lstatSync(path);
if (!stat.isSymbolicLink()) return false;
return !existsSync(path);
} catch {
return false;
}
}
function safeReadlink(path: string): string | null {
try {
return readlinkSync(path);
} catch {
return null;
}
}
function removeRecursive(path: string): void {
// Node 14.14+: rmSync. Imported lazily to keep the top imports minimal.
const { rmSync } = require("node:fs") as typeof import("node:fs");
rmSync(path, { recursive: true, force: true });
}

View File

@@ -635,7 +635,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Enables the PluginManager UI to list, install, enable, disable, and
// configure plugins via the /api/plugins REST endpoints.
//
const pluginStore = new PluginStore(store.getFusionDir());
const pluginStore = new PluginStore(store.getRootDir());
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────

View File

@@ -16,6 +16,7 @@ import { promisify } from "node:util";
const execAsync = promisify(exec);
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
import { resolveGlobalDir } from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
/** Options for the init command */
export interface InitOptions {
@@ -99,6 +100,7 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
const existing = await central.getProjectByPath(cwd);
if (existing) {
console.log(` ✓ Already registered in central database`);
maybeInstallClaudeSkillForNewProject(cwd);
console.log(`\n✓ Project "${projectName}" is ready!`);
console.log(`\n Next steps:`);
console.log(` fn task list # View tasks`);
@@ -118,6 +120,8 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
// Activate the project (registration sets it to 'initializing')
await central.updateProject(project.id, { status: "active" });
maybeInstallClaudeSkillForNewProject(cwd);
console.log(` ✓ Registered in central database`);
console.log(`\n✓ Project "${project.name}" initialized successfully!`);
console.log(`\n Next steps:`);

View File

@@ -26,6 +26,7 @@ import { resolve, isAbsolute, relative, basename } from "node:path";
import { existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { formatProjectLine, detectProjectFromCwd, setDefaultProject, resolveProject as resolveProjectContext } from "../project-context.js";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
@@ -373,6 +374,8 @@ export async function runProjectAdd(
console.warn(` ⚠ Warning: Could not initialize project memory: ${err instanceof Error ? err.message : String(err)}`);
}
maybeInstallClaudeSkillForNewProject(absolutePath);
console.log();
console.log(` ✓ Registered project '${projectName}'`);
console.log(` Location: ${formatDisplayPath(project.path)}`);

View File

@@ -373,7 +373,7 @@ export async function runServe(
// internally for task-execution plugin hooks. These instances here serve the
// HTTP plugin-management API routes and are intentionally separate.
//
const pluginStore = new PluginStore(store.getFusionDir());
const pluginStore = new PluginStore(store.getRootDir());
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────