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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,52 @@ Running `instruct-sync add <pack>` 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.
Comment on lines +91 to +95

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README claims npm install -g instruct-sync without @latest “will keep the cached version”, but earlier in the README the same command is presented as the normal install method. npm install -g <pkg> already installs the latest dist-tag by default; consider removing or rewording this to avoid giving incorrect upgrade guidance.

Copilot uses AI. Check for mistakes.


## Private repos

For private repos, set a GitHub token with read access:
Expand Down
13 changes: 11 additions & 2 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
Comment on lines +7 to +15

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The derived name for github: sources is computed from the raw input string, so it can include the @ref suffix (e.g. file.md@main) and won’t strip .md. This will create incorrect targets (like .github/instructions/file.md@main.instructions.md) and also bypass the “already installed” check for the same GitHub source. Derive the name from parseSource(source).path (basename) after parsing, so refs don’t affect naming.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +15

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior added to short-circuit add when a pack is already installed, but there are no tests covering this branch (including the expected console output / lack of network fetch). Adding a Vitest test for this case would prevent regressions.

Copilot uses AI. Check for mistakes.

let source: string;

if (nameOrSource.startsWith("github:")) {
Expand All @@ -17,7 +27,6 @@ export async function add(nameOrSource: string): Promise<void> {
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);
Expand Down
10 changes: 8 additions & 2 deletions src/commands/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Comment on lines +29 to +33

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose() now changes user-facing behavior by tracking how many packs were actually composed and adjusting the final log line when some are skipped. There are no tests for compose currently; adding a Vitest test for the “missing file(s) → skipped count in output” case would help lock this behavior in.

Copilot uses AI. Check for mistakes.
}
7 changes: 5 additions & 2 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,12 +18,13 @@ export async function update(): Promise<void> {
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`);
}
}
}
69 changes: 69 additions & 0 deletions tests/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs";

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import: existsSync is imported from node:fs but never used in this test file. Removing it will keep the test code clean and avoid unused-import lint failures if linting is added/enabled.

Suggested change
import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";

Copilot uses AI. Check for mistakes.
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();
});
});