From 0486cb81110c8a422e5cc2d3a52172ac526ebb55 Mon Sep 17 00:00:00 2001 From: zekariasasaminew Date: Tue, 3 Mar 2026 10:13:23 -0600 Subject: [PATCH] fix: restore missing files on update, warn on duplicate add, fix compose count --- README.md | 46 +++++++++++++++++++++++++++ src/commands/add.ts | 13 ++++++-- src/commands/compose.ts | 10 ++++-- src/commands/update.ts | 7 +++-- tests/update.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 tests/update.test.ts diff --git a/README.md b/README.md index 2181f09..b882586 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,52 @@ Running `instruct-sync add ` will: The `instruct-sync.json` lockfile pins exactly what you have installed. Running `instruct-sync update` compares the current SHA on GitHub against your locked SHA — if they differ it re-fetches, if they match it skips. Updates are always opt-in. +## Common scenarios + +**File was deleted — restore it** + +If you accidentally delete an instruction file, `instruct-sync list` will warn you: + +``` +react HEAD .github/instructions/react.instructions.md ⚠ file missing — run: instruct-sync update +``` + +Run `instruct-sync update` to restore all missing files. Even if the remote content hasn't changed (same SHA), it will re-write the file with `✓ restored`. + +**Add a pack that's already installed** + +```bash +instruct-sync add react +# "react" is already installed at .github/instructions/react.instructions.md. Run: instruct-sync update +``` + +Use `instruct-sync update` to pull the latest version, not `add`. + +**Remove a pack** + +There is no `remove` command yet. To remove a pack manually: +1. Delete the file (e.g. `.github/instructions/react.instructions.md`) +2. Remove the entry from `instruct-sync.json` + +**Compose with missing files** + +If some files are missing when you run `instruct-sync compose`, they are skipped and you get a clear count: + +``` +✓ Composed 2 of 3 pack(s) → .github/copilot-instructions.md (1 skipped — run: instruct-sync update) +``` + +Run `instruct-sync update` first to restore any missing files, then `compose` again. + +**Upgrade instruct-sync itself** + +```bash +npm install -g instruct-sync@latest +``` + +`npm install -g instruct-sync` without `@latest` will keep the cached version. + + ## Private repos For private repos, set a GitHub token with read access: diff --git a/src/commands/add.ts b/src/commands/add.ts index 444908b..f61dd14 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,9 +1,19 @@ import { findPack } from "../registry.js"; import { parseSource, fetchFile } from "../github.js"; import { writeInstructions, defaultTarget } from "../writer.js"; -import { addEntry } from "../lockfile.js"; +import { readLockfile, addEntry } from "../lockfile.js"; export async function add(nameOrSource: string): Promise { + const name = nameOrSource.startsWith("github:") + ? nameOrSource.split("/").pop()!.replace(/\.md$/, "").replace(/^github:/, "") + : nameOrSource; + + const existing = readLockfile().packs[name]; + if (existing) { + console.log(`"${name}" is already installed at ${existing.target}. Run: instruct-sync update`); + return; + } + let source: string; if (nameOrSource.startsWith("github:")) { @@ -17,7 +27,6 @@ export async function add(nameOrSource: string): Promise { console.log(`Fetching ${source}...`); const file = await fetchFile(parsed); - const name = nameOrSource.startsWith("github:") ? parsed.path.split("/").pop()!.replace(/\.md$/, "") : nameOrSource; const target = defaultTarget(name); writeInstructions(file.content, target); diff --git a/src/commands/compose.ts b/src/commands/compose.ts index ff1906a..85a1001 100644 --- a/src/commands/compose.ts +++ b/src/commands/compose.ts @@ -14,15 +14,21 @@ export function compose(): void { return; } const sections: string[] = [HEADER]; + let composed = 0; for (const [name, entry] of entries) { const absPath = resolve(process.cwd(), entry.target); if (!existsSync(absPath)) { - console.warn(` ⚠ skipping "${name}" — file not found at ${entry.target}`); + console.warn(` ⚠ skipping "${name}" — file not found at ${entry.target}. Run: instruct-sync update`); continue; } const content = readFileSync(absPath, "utf8").trim(); sections.push(`## ${name}\n\n${content}\n`); + composed++; } writeInstructions(sections.join("\n"), OUTPUT); - console.log(`✓ Composed ${entries.length} pack(s) → ${OUTPUT}`); + if (composed < entries.length) { + console.log(`✓ Composed ${composed} of ${entries.length} pack(s) → ${OUTPUT} (${entries.length - composed} skipped — run: instruct-sync update)`); + } else { + console.log(`✓ Composed ${composed} pack(s) → ${OUTPUT}`); + } } diff --git a/src/commands/update.ts b/src/commands/update.ts index f092472..d366212 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { readLockfile } from "../lockfile.js"; import { parseSource, fetchFile } from "../github.js"; import { writeInstructions } from "../writer.js"; @@ -16,12 +18,13 @@ export async function update(): Promise { parsed.ref = entry.ref; console.log(`Updating "${name}"...`); const file = await fetchFile(parsed); - if (file.sha === entry.sha) { + const fileExists = existsSync(resolve(process.cwd(), entry.target)); + if (file.sha === entry.sha && fileExists) { console.log(` already up to date`); } else { writeInstructions(file.content, entry.target); addEntry(name, { ...entry, sha: file.sha, installedAt: new Date().toISOString() }); - console.log(` ✓ updated`); + console.log(fileExists ? ` ✓ updated` : ` ✓ restored`); } } } diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..1e5430f --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const mockLockfile = vi.hoisted(() => ({ readLockfile: vi.fn(), addEntry: vi.fn() })); +const mockGithub = vi.hoisted(() => ({ parseSource: vi.fn(), fetchFile: vi.fn() })); +const mockWriter = vi.hoisted(() => ({ writeInstructions: vi.fn() })); + +vi.mock("../src/lockfile.js", () => mockLockfile); +vi.mock("../src/github.js", () => mockGithub); +vi.mock("../src/writer.js", () => mockWriter); + +describe("update", () => { + let cwd: string; + let originalCwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "instruct-sync-update-")); + originalCwd = process.cwd(); + process.chdir(cwd); + vi.clearAllMocks(); + mockGithub.parseSource.mockReturnValue({ owner: "o", repo: "r", path: "p.md", ref: "HEAD" }); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("restores a file that is missing even when SHA is unchanged", async () => { + mockLockfile.readLockfile.mockReturnValue({ + packs: { + react: { source: "github:o/r/p.md", ref: "HEAD", sha: "abc123", target: ".github/instructions/react.instructions.md", installedAt: "i" }, + }, + }); + mockGithub.fetchFile.mockResolvedValue({ content: "# React", sha: "abc123" }); + + const { update } = await import("../src/commands/update.js"); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await update(); + + expect(mockWriter.writeInstructions).toHaveBeenCalledWith("# React", ".github/instructions/react.instructions.md"); + const output = spy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("restored"); + spy.mockRestore(); + }); + + it("skips writing when SHA unchanged and file exists", async () => { + const target = ".github/instructions/react.instructions.md"; + mkdirSync(join(cwd, ".github/instructions"), { recursive: true }); + writeFileSync(join(cwd, target), "# React"); + + mockLockfile.readLockfile.mockReturnValue({ + packs: { + react: { source: "github:o/r/p.md", ref: "HEAD", sha: "abc123", target, installedAt: "i" }, + }, + }); + mockGithub.fetchFile.mockResolvedValue({ content: "# React", sha: "abc123" }); + + const { update } = await import("../src/commands/update.js"); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await update(); + + expect(mockWriter.writeInstructions).not.toHaveBeenCalled(); + const output = spy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("already up to date"); + spy.mockRestore(); + }); +});