Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export type { SetRulesSourceOptions } from "./commands/rules.js";
export type { PluginRegistry, RegisteredAgent, RegisteredDomain } from "./plugin-loader.js";
export { createLinker, describeSymlinkFailure, ensureLink } from "./fs/link.js";
export type { EnsureLinkResult } from "./fs/link.js";
export { createRepoFetcher } from "./resolver.js";
export { createRepoFetcher, gigetTarballPath } from "./resolver.js";
export { parseSource, parseCompositeSkillRef, isProvider, SUPPORTED_PROVIDERS } from "./source.js";
export type {
ParsedSource,
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { downloadTemplate } from "giget";
Expand Down Expand Up @@ -55,6 +56,12 @@ async function fetchGit(
await fs.rm(destDir, { recursive: true, force: true });
await fs.mkdir(path.dirname(destDir), { recursive: true });

if (opts?.noCache) {
const tarPath = gigetTarballPath(source, ref);
await fs.rm(tarPath, { force: true });
await fs.rm(`${tarPath}.json`, { force: true });
}

const gigetSource = ref
? `${source.provider}:${source.owner}/${source.repo}#${ref}`
: `${source.provider}:${source.owner}/${source.repo}`;
Expand Down Expand Up @@ -85,6 +92,26 @@ async function fetchGit(
return { path: destDir };
}

function gigetCacheDir(): string {
return process.env["XDG_CACHE_HOME"]
? path.resolve(process.env["XDG_CACHE_HOME"], "giget")
: path.resolve(os.homedir(), ".cache/giget");
}

export function gigetTarballPath(source: GitSource, ref: string | undefined): string {
const name = `${source.owner}-${source.repo}`.replace(/[^\da-z-]/gi, "-");
const version = ref ?? "main";
const sourceDir = path.resolve(gigetCacheDir(), source.provider, name);
const tarPath = path.resolve(sourceDir, `${version}.tar.gz`);
const rel = path.relative(sourceDir, tarPath);
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
throw new Error(
`Refusing to compute giget tarball path: ref "${ref ?? ""}" escapes source cache dir`,
);
}
return tarPath;
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
rgdevme marked this conversation as resolved.

async function dirHasFiles(p: string): Promise<boolean> {
try {
const entries = await fs.readdir(p);
Expand Down
100 changes: 100 additions & 0 deletions packages/core/test/resolver-nocache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";

const downloadTemplate = vi.fn(async (_src: string, opts: { dir: string }) => {
await fs.mkdir(opts.dir, { recursive: true });
await fs.writeFile(path.join(opts.dir, "marker.txt"), "ok");
return { dir: opts.dir };
});

vi.mock("giget", () => ({
downloadTemplate: (src: string, opts: { dir: string }) => downloadTemplate(src, opts),
}));

import { createRepoFetcher, gigetTarballPath, parseSource } from "../src/index.js";

describe("createRepoFetcher noCache", () => {
let root: string;
let cacheHome: string;
let originalXdg: string | undefined;

beforeEach(async () => {
downloadTemplate.mockClear();
root = await fs.mkdtemp(path.join(os.tmpdir(), "agnos-resolver-"));
cacheHome = await fs.mkdtemp(path.join(os.tmpdir(), "agnos-xdg-"));
originalXdg = process.env["XDG_CACHE_HOME"];
process.env["XDG_CACHE_HOME"] = cacheHome;
});

afterEach(async () => {
if (originalXdg === undefined) delete process.env["XDG_CACHE_HOME"];
else process.env["XDG_CACHE_HOME"] = originalXdg;
await fs.rm(root, { recursive: true, force: true });
await fs.rm(cacheHome, { recursive: true, force: true });
});

it("wipes giget's cached tarball before fetching when noCache is set", async () => {
const source = parseSource("github:vercel-labs/agent-skills", { projectRoot: root });
if (source.kind !== "git") throw new Error("expected git source");

const tarPath = gigetTarballPath(source, undefined);
await fs.mkdir(path.dirname(tarPath), { recursive: true });
await fs.writeFile(tarPath, "stale-tarball");
await fs.writeFile(`${tarPath}.json`, JSON.stringify({ etag: "old" }));

const fetcher = createRepoFetcher({
projectRoot: root,
cacheDir: path.join(root, ".agnos", "cache"),
});
await fetcher.fetch(source, { noCache: true });

await expect(fs.access(tarPath)).rejects.toThrow();
await expect(fs.access(`${tarPath}.json`)).rejects.toThrow();
expect(downloadTemplate).toHaveBeenCalledOnce();
});

it("rejects refs that escape the source's cache directory", async () => {
const source = parseSource("github:vercel-labs/agent-skills", { projectRoot: root });
if (source.kind !== "git") throw new Error("expected git source");

const sentinelDir = path.join(cacheHome, "giget", "github", "other-repo");
await fs.mkdir(sentinelDir, { recursive: true });
const sentinel = path.join(sentinelDir, "main.tar.gz");
await fs.writeFile(sentinel, "sibling-tarball");

expect(() => gigetTarballPath(source, "../../other-repo/main")).toThrow(/escapes/);

const fetcher = createRepoFetcher({
projectRoot: root,
cacheDir: path.join(root, ".agnos", "cache"),
});
await expect(
fetcher.fetch(source, { ref: "../../other-repo/main", noCache: true }),
).rejects.toThrow(/escapes/);

// The sibling tarball must not have been touched.
const sibling = await fs.readFile(sentinel, "utf8");
expect(sibling).toBe("sibling-tarball");
expect(downloadTemplate).not.toHaveBeenCalled();
});

it("leaves giget's cached tarball untouched when noCache is not set", async () => {
const source = parseSource("github:vercel-labs/agent-skills", { projectRoot: root });
if (source.kind !== "git") throw new Error("expected git source");

const tarPath = gigetTarballPath(source, undefined);
await fs.mkdir(path.dirname(tarPath), { recursive: true });
await fs.writeFile(tarPath, "stale-tarball");

const fetcher = createRepoFetcher({
projectRoot: root,
cacheDir: path.join(root, ".agnos", "cache"),
});
await fetcher.fetch(source);

const stillThere = await fs.readFile(tarPath, "utf8");
expect(stillThere).toBe("stale-tarball");
});
});
2 changes: 1 addition & 1 deletion packages/domain-docs/src/cli/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
const data = (parsed.data ?? {}) as Record<string, unknown>;
const relativeFromRoute = path.relative(cfg.route, abs).split(path.sep).join("/");
const segments = relativeFromRoute.split("/");
const topDir = segments.length > 1 ? segments[0]! : "";

Check warning on line 81 in packages/domain-docs/src/cli/generate.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
return {
absolutePath: abs,
relativeFromRoute,
Expand Down Expand Up @@ -114,7 +114,7 @@
});
const lines: string[] = [];
for (const section of sectionOrder) {
lines.push(`## ${section}`);
lines.push(`### ${section}`);
for (const d of grouped.get(section) ?? []) {
const desc = d.description ? `: ${d.description}` : "";
lines.push(`- [${d.title}](${d.relativeFromRoute})${desc}`);
Expand All @@ -128,7 +128,7 @@
if (docs.length === 0) return "";
const lines: string[] = [];
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!;

Check warning on line 131 in packages/domain-docs/src/cli/generate.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
if (i > 0) {
lines.push("");
lines.push("---");
Expand All @@ -146,7 +146,7 @@
return dir
.split(/[-_]/g)
.filter(Boolean)
.map((word) => word[0]!.toUpperCase() + word.slice(1))

Check warning on line 149 in packages/domain-docs/src/cli/generate.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
.join(" ");
}

Expand Down
8 changes: 4 additions & 4 deletions packages/domain-docs/src/cli/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import path from "node:path";
import type { CliCommand, ResolveContext } from "@luxia/core";
import { readConfigOrDefault } from "@luxia/core";
import { readEffectiveDocsConfig, type EffectiveDocsConfig } from "../effective-config.js";
import { INDEX_BLOCK, RULES_BLOCK } from "../schema.js";
import { replaceBetweenMarkers, stripFrontmatter } from "../inject/markers.js";
import { INDEX_HEADING, RULES_HEADING } from "../schema.js";
import { replaceUnderHeading, stripFrontmatter } from "../inject/markers.js";

export const inject: CliCommand = {
description: "Inject doc-rules and index into the project's rules file",
Expand Down Expand Up @@ -40,14 +40,14 @@ export async function runInject(
if (cfg.injectRules) {
const payload = await readBodyOrEmpty(cfg.docRulesFile);
if (payload !== null) {
const next = replaceBetweenMarkers(text, RULES_BLOCK.start, RULES_BLOCK.end, payload);
const next = replaceUnderHeading(text, RULES_HEADING, payload);
text = next.text;
}
}
if (cfg.injectIndex) {
const payload = await readBodyOrEmpty(cfg.indexFile);
if (payload !== null) {
const next = replaceBetweenMarkers(text, INDEX_BLOCK.start, INDEX_BLOCK.end, payload);
const next = replaceUnderHeading(text, INDEX_HEADING, payload);
text = next.text;
}
}
Expand Down
42 changes: 23 additions & 19 deletions packages/domain-docs/src/inject/markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,41 @@
}

/**
* Replace content between `startMarker` and `endMarker` lines.
* Markers themselves are preserved verbatim.
* Replace the content under a `## Heading` (or `# Heading`) line.
* The block ends at the next sibling-or-higher heading (`## ` or `# `) or EOF.
*
* - If both markers are present and in correct order, replace the lines between them with `payload`.
* - If either marker is missing, append `<blank line><startMarker>\n<payload>\n<endMarker>` at the end.
* - Equality short-circuit: if the resulting text equals input, returns changed=false.
* - Heading absent: append `<blank line><heading>\n<payload>\n` at end.
* - Heading present: replace lines after the heading up to the boundary with
* `payload`, then a single blank line before the boundary (or EOF).
* - Equality short-circuit: if the resulting text equals input, changed=false.
*
* `payload` is inserted verbatim between markers; callers strip leading/trailing whitespace as desired.
* Payload is inserted verbatim; callers strip leading/trailing whitespace as
* desired.
*/
export function replaceBetweenMarkers(
text: string,
startMarker: string,
endMarker: string,
payload: string,
): ReplaceResult {
export function replaceUnderHeading(text: string, heading: string, payload: string): ReplaceResult {
const lines = text.split(/\r?\n/);
const startIdx = lines.findIndex((line) => line === startMarker);
const endIdx =
startIdx >= 0 ? lines.findIndex((line, i) => i > startIdx && line === endMarker) : -1;
const startIdx = lines.findIndex((line) => line === heading);

if (startIdx < 0 || endIdx < 0) {
const trailingBlank = text.endsWith("\n") ? "" : "\n";
if (startIdx < 0) {
const trailingNewline = text.endsWith("\n") ? "" : "\n";
const sep = text.length === 0 ? "" : "\n";
const newText = `${text}${trailingBlank}${sep}${startMarker}\n${payload}\n${endMarker}\n`;
const newText = `${text}${trailingNewline}${sep}${heading}\n${payload}\n`;
return { text: newText, changed: true, appended: true };
}

let endIdx = lines.length;
for (let j = startIdx + 1; j < lines.length; j++) {
if (/^#{1,2} /.test(lines[j]!)) {

Check warning on line 32 in packages/domain-docs/src/inject/markers.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
endIdx = j;
break;
}
}

const before = lines.slice(0, startIdx + 1);
const after = lines.slice(endIdx);
const payloadLines = payload.split(/\r?\n/);
const next = [...before, ...payloadLines, ...after].join("\n");
const tail = after.length === 0 ? [] : ["", ...after];
const next = [...before, ...payloadLines, ...tail].join("\n");
return { text: next, changed: next !== text, appended: false };
}

Expand Down
11 changes: 2 additions & 9 deletions packages/domain-docs/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,5 @@ export const DEFAULTS = {
injectRules: true,
};

export const RULES_BLOCK = {
start: "## Documentation Rules",
end: ">__Documentation rules end__",
};

export const INDEX_BLOCK = {
start: "## Documentation Index",
end: ">__Documentation index end__",
};
export const RULES_HEADING = "## Documentation Rules";
export const INDEX_HEADING = "## Documentation Index";
4 changes: 2 additions & 2 deletions packages/domain-docs/test/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
function extractFrontmatterBlock(text: string): Record<string, string> {
const match = /^```frontmatter[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/m.exec(text);
if (!match) throw new Error("no ```frontmatter block found");
const body = match[1]!.replace(/^---\r?\n/, "").replace(/\r?\n---$/, "");

Check warning on line 14 in packages/domain-docs/test/generate.test.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
return yaml.load(body) as Record<string, string>;
}

Expand Down Expand Up @@ -83,12 +83,12 @@
expect(result.docRulesChanged).toBe(true);

const indexText = await fs.readFile(cfg.indexFile, "utf8");
expect(indexText).toContain("## Overview");
expect(indexText).toContain("## Getting Started");
expect(indexText).toContain("### Overview");
expect(indexText).toContain("### Getting Started");
expect(indexText).toContain("[Auth Flow](auth-flow.md): How auth works.");
expect(indexText).toContain("[Local setup](getting-started/local.md): Run it locally.");

const contentText = await fs.readFile(cfg.contentFile!, "utf8");

Check warning on line 91 in packages/domain-docs/test/generate.test.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
expect(contentText).toContain("# Auth Flow");
expect(contentText).toContain("Auth body.");
expect(contentText).toContain("# Local setup");
Expand Down Expand Up @@ -176,7 +176,7 @@
await runGenerate(cfg, ctxFor(root));

const indexText = await fs.readFile(cfg.indexFile, "utf8");
const fm = indexText.split("---")[1]!;

Check warning on line 179 in packages/domain-docs/test/generate.test.ts

View workflow job for this annotation

GitHub Actions / verify

Forbidden non-null assertion
const keys = fm
.trim()
.split("\n")
Expand Down
4 changes: 3 additions & 1 deletion packages/domain-docs/test/inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe("runInject gating", () => {
);
await fs.writeFile(
path.join(root, "AGENTS.md"),
"# AGENTS\n\n## Documentation Rules\n>__Documentation rules end__\n\n## Documentation Index\n>__Documentation index end__\n",
"# AGENTS\n\n## Documentation Rules\n\n## Documentation Index\n\n## Trailing Section\n",
);
await fs.mkdir(path.join(root, ".docs"), { recursive: true });
await fs.writeFile(path.join(root, ".docs", "index.md"), "- [foo](foo.md)\n");
Expand All @@ -95,5 +95,7 @@ describe("runInject gating", () => {
const after = await fs.readFile(path.join(root, "AGENTS.md"), "utf8");
expect(after).toContain("- [foo](foo.md)");
expect(after).toContain("rule line");
expect(after).not.toContain(">__Documentation");
expect(after).toContain("## Trailing Section");
});
});
Loading
Loading