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
41 changes: 22 additions & 19 deletions backend/src/__tests__/vault-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,35 +186,36 @@ describe("installCommands", () => {
expect(files).toContain("weekly-synthesis.md");
});

test("skips existing files", async () => {
test("updates existing files on second install", async () => {
// First install
const result1 = await installCommands(vaultPath);
expect(result1.success).toBe(true);
expect(result1.installed.length).toBeGreaterThan(0);

// Second install should skip all
// Second install should update all (server-owned files)
const result2 = await installCommands(vaultPath);
expect(result2.success).toBe(true);
expect(result2.installed.length).toBe(0);
expect(result2.message).toContain("already existed");
expect(result2.installed.length).toBe(0); // None are "new"
expect(result2.message).toContain("Updated");
});

test("does not overwrite existing files with different content", async () => {
test("overwrites existing files with server version", async () => {
// Create commands directory with a custom file
const commandsDir = join(vaultPath, COMMANDS_DEST_PATH);
await mkdir(commandsDir, { recursive: true });
const customContent = "# Custom daily-debrief content\n\nDo not overwrite me.";
const customContent = "# Custom daily-debrief content\n\nThis will be overwritten.";
await writeFile(join(commandsDir, "daily-debrief.md"), customContent);

// Run install
await installCommands(vaultPath);

// Verify custom file was preserved
// Verify custom file was replaced with server version
const content = await readFile(join(commandsDir, "daily-debrief.md"), "utf-8");
expect(content).toBe(customContent);
expect(content).not.toBe(customContent);
expect(content).toContain("Quick focused conversation"); // Should have the real content
});

test("handles mixed existing and new files", async () => {
test("reports mixed installed and updated files", async () => {
// Create commands directory with one existing file
const commandsDir = join(vaultPath, COMMANDS_DEST_PATH);
await mkdir(commandsDir, { recursive: true });
Expand All @@ -223,10 +224,11 @@ describe("installCommands", () => {
const result = await installCommands(vaultPath);

expect(result.success).toBe(true);
// Should install all except daily-debrief.md
// daily-debrief.md was updated, others were installed
expect(result.installed).not.toContain("daily-debrief.md");
expect(result.installed.length).toBeGreaterThan(0);
expect(result.message).toContain("already existed");
expect(result.message).toContain("Installed");
expect(result.message).toContain("Updated");
});

test("returns list of installed commands", async () => {
Expand Down Expand Up @@ -318,32 +320,33 @@ describe("installSkills", () => {
expect(stats.mode & 0o100).toBeTruthy();
});

test("skips existing skill directories", async () => {
test("updates existing skills on second install", async () => {
// First install
const result1 = await installSkills(vaultPath);
expect(result1.success).toBe(true);
expect(result1.installed.length).toBeGreaterThan(0);

// Second install should skip all
// Second install should update all (server-owned)
const result2 = await installSkills(vaultPath);
expect(result2.success).toBe(true);
expect(result2.installed.length).toBe(0);
expect(result2.message).toContain("already existed");
expect(result2.installed.length).toBe(0); // None are "new"
expect(result2.message).toContain("Updated");
});

test("does not overwrite existing skill", async () => {
test("overwrites existing skill with server version", async () => {
// Create skills directory with a custom file in the skill
const skillDir = join(vaultPath, SKILLS_DEST_PATH, "vault-task-management");
await mkdir(skillDir, { recursive: true });
const customContent = "# Custom SKILL.md content\n\nDo not overwrite me.";
const customContent = "# Custom SKILL.md content\n\nThis will be overwritten.";
await writeFile(join(skillDir, "SKILL.md"), customContent);

// Run install
await installSkills(vaultPath);

// Verify custom file was preserved
// Verify custom file was replaced with server version
const content = await readFile(join(skillDir, "SKILL.md"), "utf-8");
expect(content).toBe(customContent);
expect(content).not.toBe(customContent);
expect(content).toContain("vault"); // Should have the real content
});

test("returns list of installed skills", async () => {
Expand Down
66 changes: 40 additions & 26 deletions backend/src/vault-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* 4. Write setup completion marker
*/

import { copyFile, mkdir, readdir, readFile, writeFile, stat, chmod } from "node:fs/promises";
import { copyFile, mkdir, readdir, readFile, writeFile, stat, chmod, rm } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createLogger } from "./logger";
Expand Down Expand Up @@ -135,7 +135,7 @@ function getCommandTemplatesDir(): string {

/**
* Installs command templates to the vault's .claude/commands/ directory.
* Skips files that already exist (does not overwrite).
* Always overwrites existing files (server-owned, not user-customizable).
*
* @param vaultPath - Absolute path to the vault root
* @returns Setup step result with list of installed commands
Expand All @@ -146,7 +146,7 @@ export async function installCommands(
log.info(`Installing commands to ${vaultPath}`);

const installed: string[] = [];
const skipped: string[] = [];
const updated: string[] = [];
const errors: string[] = [];

const templatesDir = getCommandTemplatesDir();
Expand Down Expand Up @@ -194,23 +194,24 @@ export async function installCommands(
};
}

// Copy each template
// Copy each template (always overwrite - server-owned files)
for (const template of templates) {
const srcPath = join(templatesDir, template);
const destPath = join(destDir, template);

// Check if file already exists
if (await fileExists(destPath)) {
log.debug(`Skipping existing: ${template}`);
skipped.push(template);
continue;
}
// Track if this is an update vs new install
const existed = await fileExists(destPath);

// Copy template
try {
await copyFile(srcPath, destPath);
log.info(`Installed: ${template}`);
installed.push(template);
if (existed) {
log.debug(`Updated: ${template}`);
updated.push(template);
} else {
log.info(`Installed: ${template}`);
installed.push(template);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`Failed to install ${template}: ${message}`);
Expand All @@ -223,8 +224,8 @@ export async function installCommands(
if (installed.length > 0) {
parts.push(`Installed ${installed.length} command(s)`);
}
if (skipped.length > 0) {
parts.push(`${skipped.length} already existed`);
if (updated.length > 0) {
parts.push(`Updated ${updated.length} command(s)`);
}

const resultMessage = parts.join(", ") || "No commands to install";
Expand Down Expand Up @@ -299,7 +300,7 @@ async function copyDirectoryRecursive(
/**
* Installs skill directories to the vault's .claude/skills/ directory.
* Each skill is a directory containing SKILL.md and supporting files.
* Skips skills that already exist (does not overwrite).
* Always overwrites existing skills (server-owned, not user-customizable).
*
* @param vaultPath - Absolute path to the vault root
* @returns Setup step result with list of installed skills
Expand All @@ -310,7 +311,7 @@ export async function installSkills(
log.info(`Installing skills to ${vaultPath}`);

const installed: string[] = [];
const skipped: string[] = [];
const updated: string[] = [];
const errors: string[] = [];

const templatesDir = getSkillTemplatesDir();
Expand Down Expand Up @@ -358,23 +359,36 @@ export async function installSkills(
};
}

// Copy each skill directory
// Copy each skill directory (always overwrite - server-owned)
for (const skillName of skillDirs) {
const srcPath = join(templatesDir, skillName);
const destPath = join(destDir, skillName);

// Check if skill already exists
if (await directoryExists(destPath)) {
log.debug(`Skipping existing skill: ${skillName}`);
skipped.push(skillName);
continue;
// Track if this is an update vs new install
const existed = await directoryExists(destPath);

// Remove existing skill directory to avoid orphan files
if (existed) {
try {
await rm(destPath, { recursive: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`Failed to remove existing skill ${skillName}: ${message}`);
errors.push(`${skillName}: failed to remove existing: ${message}`);
continue;
}
}

// Copy skill directory recursively
try {
await copyDirectoryRecursive(srcPath, destPath, vaultPath);
log.info(`Installed skill: ${skillName}`);
installed.push(skillName);
if (existed) {
log.debug(`Updated skill: ${skillName}`);
updated.push(skillName);
} else {
log.info(`Installed skill: ${skillName}`);
installed.push(skillName);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`Failed to install skill ${skillName}: ${message}`);
Expand All @@ -387,8 +401,8 @@ export async function installSkills(
if (installed.length > 0) {
parts.push(`Installed ${installed.length} skill(s)`);
}
if (skipped.length > 0) {
parts.push(`${skipped.length} already existed`);
if (updated.length > 0) {
parts.push(`Updated ${updated.length} skill(s)`);
}

const resultMessage = parts.join(", ") || "No skills to install";
Expand Down