mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
b0c789af70
* docs(10xdevs4-cli-access): record membership gates and bootstrap context (p1) Add canonical access plan, accepted decisions and supporting context. Record phase 1 verification, Toolkit revision and remaining evidence gaps. * docs(10xdevs4-cli-access): record course access gates and scoped review (p2) Update canonical Progress, change status, evidence and implementation review. Record Toolkit revisions, inherited typecheck limitation and phase 3 prerequisites. * docs(10xdevs4-cli-access): persist reviewed revisions and phase 3 boundary Record final reviewed Toolkit and CLI context revisions. Persist Progress attribution and unresolved W04/W05/W08 prerequisites. Keep phases 3–6, phase 7 and Manual criteria open. * docs(10xdevs4-cli-access): record squash-safe source prerequisite Record PR #30, verified gates, permanent-pin lessons and the remaining merge dependency. Keep phase 3 and all manual rollout criteria pending. * docs(10xdevs4-cli-access): record source prerequisite review Record independent review of PR #30 and verified CI evidence. Keep the master pin and delivery phase completion pending. * docs(10xdevs4-cli-access): distinguish candidate checks from final master pins Record passing pre-merge v4 checks and defer workflow suspension. Keep the final v3 maintenance pin dependent on the resulting master SHA. * feat: prepare v4 course delivery and protected project sync Capture the reviewed implementation and manual rehearsal for draft PR review. Master source prerequisites, full clean verification and coordinated Windows CI remain open; production rollout is separate. * fix: keep paid CI evidence private and converge cumulative sync * docs: record merged source prerequisite and passing clean gate * fix: preserve generated API type line endings on Windows --------- Co-authored-by: Claude <noreply@anthropic.com>
686 lines
31 KiB
TypeScript
686 lines
31 KiB
TypeScript
/**
|
|
* 10x sync — command-level behavior.
|
|
*
|
|
* Mocks api-content via the shared helper; writes a valid auth file + isolated
|
|
* config dir; chdir's into a per-test temp project root so applyBundle writes
|
|
* there. stdout is forced non-TTY, so resolveContext implies JSON — assertions
|
|
* read the JSON envelope on stdout and the exit code, not human strings.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import cac from "cac";
|
|
import type { ApiResult } from "../src/lib/api-client";
|
|
import type { CatalogResponse, LessonBundle, LessonSummary } from "../src/lib/api-content";
|
|
import { AUTH_FILE_VERSION, type AuthData, saveAuth } from "../src/lib/config";
|
|
import { MANIFEST_FILENAME } from "../src/lib/manifest";
|
|
import { apiContentMockState, resetApiContentMock } from "./helpers/api-content-mock";
|
|
import { redirectConfigDir, restoreConfigDir } from "./helpers/config-isolation";
|
|
|
|
interface CaptureResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode?: number;
|
|
}
|
|
|
|
function captureStreams(fn: () => Promise<unknown>): Promise<CaptureResult> {
|
|
return new Promise((resolve) => {
|
|
const realExit = process.exit;
|
|
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
const realStderrWrite = process.stderr.write.bind(process.stderr);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
process.exit = ((code?: number) => {
|
|
throw Object.assign(new Error("__exit__"), { __exitCode: code });
|
|
}) as typeof process.exit;
|
|
|
|
// Restore SYNCHRONOUSLY inside the settled handler, before resolve(), so a
|
|
// test that runs the command twice doesn't have the first call's restore
|
|
// (in a trailing .finally) clobber the second call's stream capture.
|
|
const restore = () => {
|
|
process.stdout.write = realStdoutWrite;
|
|
process.stderr.write = realStderrWrite;
|
|
process.exit = realExit;
|
|
};
|
|
|
|
fn().then(
|
|
() => {
|
|
restore();
|
|
resolve({ stdout, stderr });
|
|
},
|
|
(err: unknown) => {
|
|
restore();
|
|
if (err && typeof err === "object" && "__exitCode" in err) {
|
|
resolve({ stdout, stderr, exitCode: (err as { __exitCode: number }).__exitCode });
|
|
} else {
|
|
resolve({
|
|
stdout,
|
|
stderr: `${stderr}\n[uncaught: ${err instanceof Error ? err.message : String(err)}]`,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
interface ParsedFlags {
|
|
json?: boolean;
|
|
verbose?: boolean;
|
|
all?: boolean;
|
|
dryRun?: boolean;
|
|
force?: boolean;
|
|
module?: string;
|
|
course?: string;
|
|
tool?: string;
|
|
lang?: string;
|
|
}
|
|
|
|
/** Minimal argv→flags parser for the subset of options these tests pass. */
|
|
function parseArgs(argv: string[]): ParsedFlags {
|
|
const flags: ParsedFlags = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i]!;
|
|
if (a === "--all") flags.all = true;
|
|
else if (a === "--dry-run") flags.dryRun = true;
|
|
else if (a === "--force") flags.force = true;
|
|
else if (a === "--module") flags.module = argv[++i];
|
|
else if (a === "--course") flags.course = argv[++i];
|
|
else if (a === "--tool") flags.tool = argv[++i];
|
|
else if (a === "--lang") flags.lang = argv[++i];
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
/**
|
|
* Invoke the command's exported runSync directly with a parsed options object.
|
|
* Avoids cac re-entrancy when a single test runs sync more than once; the cac
|
|
* wiring itself is covered by index.ts registration + the smoke test.
|
|
*/
|
|
async function runSyncCmd(argv: string[]): Promise<CaptureResult> {
|
|
return captureStreams(async () => {
|
|
const { runSync } = await import("../src/commands/sync");
|
|
const { resolveContext } = await import("../src/lib/output");
|
|
const flags = parseArgs(argv);
|
|
await runSync(resolveContext(flags), flags);
|
|
});
|
|
}
|
|
|
|
function envelope(stdout: string): { status: string; data: Record<string, unknown> } {
|
|
const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
|
|
return JSON.parse(line);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let tmp: string;
|
|
let priorIsTTY: boolean | undefined;
|
|
let priorCwd: string;
|
|
let fetched: string[];
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), "10x-cli-sync-"));
|
|
redirectConfigDir(tmp);
|
|
priorIsTTY = process.stdout.isTTY;
|
|
process.stdout.isTTY = false;
|
|
priorCwd = process.cwd();
|
|
process.chdir(tmp);
|
|
fetched = [];
|
|
resetApiContentMock();
|
|
writeValidAuth();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(priorCwd);
|
|
restoreConfigDir();
|
|
if (priorIsTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
else process.stdout.isTTY = priorIsTTY;
|
|
resetApiContentMock();
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function writeValidAuth(): void {
|
|
const data: AuthData = {
|
|
version: AUTH_FILE_VERSION,
|
|
email: "student@example.com",
|
|
access_token: "jwt-valid",
|
|
refresh_token: "rt-valid",
|
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1_000).toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
saveAuth(data);
|
|
}
|
|
|
|
function lessonSummary(over: Partial<LessonSummary> & { lessonId: string; module: number; lesson: number }): LessonSummary {
|
|
return {
|
|
title: `Lesson ${over.lessonId}`,
|
|
summary: "summary",
|
|
bundlePath: `10xdevs3/lessons/${over.lessonId}.json`,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function makeCatalog(lessons: LessonSummary[], lockedModules: number[] = []): CatalogResponse {
|
|
const moduleNums = [...new Set(lessons.map((l) => l.module).concat(lockedModules))].sort();
|
|
return {
|
|
course: "10xdevs3",
|
|
modules: moduleNums.map((m) => ({
|
|
module: m,
|
|
title: `Module ${m}`,
|
|
releaseAt: "2026-04-01T00:00:00Z",
|
|
stateOverride: null,
|
|
effectiveState: lockedModules.includes(m) ? "locked" : "unlocked",
|
|
})),
|
|
lessons,
|
|
};
|
|
}
|
|
|
|
function makeBundle(lessonId: string, skillContent: string): LessonBundle {
|
|
const m = /^m(\d+)l(\d+)$/.exec(lessonId)!;
|
|
return {
|
|
lessonId,
|
|
module: Number(m[1]),
|
|
lesson: Number(m[2]),
|
|
title: `Lesson ${lessonId}`,
|
|
summary: "summary",
|
|
skills: [{ name: "auth-skill", files: [{ path: "SKILL.md", content: skillContent }] }],
|
|
prompts: [],
|
|
rules: [],
|
|
configs: [],
|
|
};
|
|
}
|
|
|
|
function okCatalog(catalog: CatalogResponse): ApiResult<CatalogResponse> {
|
|
return { ok: true, data: catalog, status: 200, responseHeaders: new Headers(), rawBody: "" };
|
|
}
|
|
|
|
function okLesson(bundle: LessonBundle): ApiResult<LessonBundle> {
|
|
return { ok: true, data: bundle, status: 200, responseHeaders: new Headers(), rawBody: "" };
|
|
}
|
|
|
|
/** Wire the catalog + a per-lesson bundle map, tracking fetched lesson ids. */
|
|
function wire(catalog: CatalogResponse, bundles: Record<string, LessonBundle>): void {
|
|
apiContentMockState.fetchCatalogImpl = () => okCatalog(catalog);
|
|
apiContentMockState.fetchLessonImpl = (_course, lessonId) => {
|
|
fetched.push(lessonId);
|
|
const bundle = bundles[lessonId];
|
|
if (!bundle) {
|
|
return { ok: false, status: 404, code: "lesson_not_found", error: "missing" } as ApiResult<LessonBundle>;
|
|
}
|
|
return okLesson(bundle);
|
|
};
|
|
}
|
|
|
|
function readManifestFile(): Record<string, unknown> {
|
|
return JSON.parse(readFileSync(join(tmp, ".claude", MANIFEST_FILENAME), "utf8"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("10x sync — cac wiring", () => {
|
|
it("is registered and runs via the cac instance", async () => {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
const res = await captureStreams(async () => {
|
|
const { registerSyncCommand } = await import("../src/commands/sync");
|
|
const cli = cac("10x");
|
|
cli.option("--json", "Output as JSON");
|
|
cli.option("--verbose", "Verbose");
|
|
registerSyncCommand(cli);
|
|
cli.parse(["bun", "10x", "sync", "--all", "--tool", "claude-code"], { run: false });
|
|
await cli.runMatchedCommand();
|
|
});
|
|
expect(res.exitCode).toBeUndefined();
|
|
expect(envelope(res.stdout).status).toBe("ok");
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
});
|
|
});
|
|
|
|
describe("10x sync — bulk download (--all)", () => {
|
|
it("downloads all unlocked lessons and writes their files", async () => {
|
|
const catalog = makeCatalog([
|
|
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h-m1l1" }),
|
|
lessonSummary({ lessonId: "m1l2", module: 1, lesson: 2, contentHash: "h-m1l2" }),
|
|
]);
|
|
wire(catalog, { m1l1: makeBundle("m1l1", "v1"), m1l2: makeBundle("m1l2", "v1") });
|
|
|
|
const res = await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
expect(res.exitCode).toBeUndefined();
|
|
expect(fetched.sort()).toEqual(["m1l1", "m1l2"]);
|
|
expect(existsSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"))).toBe(true);
|
|
const data = envelope(res.stdout).data;
|
|
expect((data.lessons as unknown[]).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("10x sync — default targets manifest lessons; --module filters", () => {
|
|
it("default mode only targets already-downloaded lessons", async () => {
|
|
// Seed manifest with m1l1 only.
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
// Now catalog also offers m1l2 (never downloaded) and m1l1 changed.
|
|
fetched = [];
|
|
wire(
|
|
makeCatalog([
|
|
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1-changed" }),
|
|
lessonSummary({ lessonId: "m1l2", module: 1, lesson: 2, contentHash: "h2" }),
|
|
]),
|
|
{ m1l1: makeBundle("m1l1", "v2"), m1l2: makeBundle("m1l2", "v1") },
|
|
);
|
|
const res = await runSyncCmd(["--tool", "claude-code"]);
|
|
|
|
// m1l2 is not in the manifest → not targeted/fetched.
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
const ids = (envelope(res.stdout).data.lessons as Array<{ lessonId: string }>).map((l) => l.lessonId);
|
|
expect(ids).toEqual(["m1l1"]);
|
|
});
|
|
|
|
it("--module filters to one module", async () => {
|
|
wire(
|
|
makeCatalog([
|
|
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "a" }),
|
|
lessonSummary({ lessonId: "m2l1", module: 2, lesson: 1, contentHash: "b" }),
|
|
]),
|
|
{ m1l1: makeBundle("m1l1", "v1"), m2l1: makeBundle("m2l1", "v1") },
|
|
);
|
|
await runSyncCmd(["--all", "--module", "m2", "--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m2l1"]);
|
|
});
|
|
});
|
|
|
|
describe("10x sync — cheap-skip via catalog digest", () => {
|
|
it("does NOT fetch a lesson whose catalog contentHash matches the stored digest", async () => {
|
|
const catalog = makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]);
|
|
wire(catalog, { m1l1: makeBundle("m1l1", "v1") });
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]); // seeds manifest digest h1
|
|
|
|
fetched = [];
|
|
const res = await runSyncCmd(["--tool", "claude-code"]); // same catalog (h1)
|
|
|
|
expect(fetched).toEqual([]); // cheap-skip: no download
|
|
const lessons = envelope(res.stdout).data.lessons as Array<{ lessonId: string; status: string; fetched: boolean }>;
|
|
expect(lessons[0]).toMatchObject({ lessonId: "m1l1", status: "unchanged", fetched: false });
|
|
});
|
|
|
|
it("fetches a lesson whose digest differs", async () => {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
fetched = [];
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h2" })]), {
|
|
m1l1: makeBundle("m1l1", "v2"),
|
|
});
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
});
|
|
|
|
it("--force bypasses the gate and fetches even when the digest matches", async () => {
|
|
const catalog = makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]);
|
|
wire(catalog, { m1l1: makeBundle("m1l1", "v1") });
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
fetched = [];
|
|
await runSyncCmd(["--force", "--tool", "claude-code"]); // same digest h1
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
});
|
|
});
|
|
|
|
describe("10x sync — manifest digest round-trip", () => {
|
|
it("stores the catalog contentHash into the manifest on apply", async () => {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "digest-xyz" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
const manifest = readManifestFile() as { lessons: Record<string, { catalogContentHash?: string }> };
|
|
expect(manifest.lessons["m1l1"]!.catalogContentHash).toBe("digest-xyz");
|
|
});
|
|
});
|
|
|
|
describe("10x sync — conflicts", () => {
|
|
it("default reports skipped-conflict with a remediation command; local edit preserved", async () => {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
// User edits the local skill, upstream also moves.
|
|
writeFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "locally edited");
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h2" })]), {
|
|
m1l1: makeBundle("m1l1", "v2"),
|
|
});
|
|
const res = await runSyncCmd(["--tool", "claude-code"]);
|
|
|
|
expect(res.exitCode).toBeUndefined(); // conflicts alone do NOT fail
|
|
const lessons = envelope(res.stdout).data.lessons as Array<{
|
|
resources: Array<{ bucket: string; remediation?: string }>;
|
|
}>;
|
|
const conflict = lessons[0]!.resources.find((r) => r.bucket === "skipped-conflict");
|
|
expect(conflict).toBeTruthy();
|
|
expect(conflict!.remediation).toBe("10x get m1l1 --type skills --name auth-skill");
|
|
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("locally edited");
|
|
});
|
|
|
|
it("--force overwrites the conflicted file with upstream", async () => {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
writeFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "locally edited");
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h2" })]), {
|
|
m1l1: makeBundle("m1l1", "v2"),
|
|
});
|
|
|
|
await runSyncCmd(["--force", "--tool", "claude-code"]);
|
|
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("v2");
|
|
});
|
|
});
|
|
|
|
describe("10x sync — dry-run", () => {
|
|
it("writes nothing and still reports the plan", async () => {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
|
|
m1l1: makeBundle("m1l1", "v1"),
|
|
});
|
|
const res = await runSyncCmd(["--all", "--dry-run", "--tool", "claude-code"]);
|
|
|
|
expect(fetched).toEqual(["m1l1"]); // dry-run still fetches to classify
|
|
expect(existsSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"))).toBe(false); // no write
|
|
expect(existsSync(join(tmp, ".claude", MANIFEST_FILENAME))).toBe(false); // no manifest write
|
|
const lessons = envelope(res.stdout).data.lessons as Array<{ resources: Array<{ bucket: string }> }>;
|
|
expect(lessons[0]!.resources.some((r) => r.bucket === "created")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("10x sync — partial failure", () => {
|
|
it("exits 1 when a lesson errors but still emits the full report", async () => {
|
|
const catalog = makeCatalog([
|
|
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "a" }),
|
|
lessonSummary({ lessonId: "m1l2", module: 1, lesson: 2, contentHash: "b" }),
|
|
]);
|
|
// m1l2 has no bundle → wire() returns 404 for it.
|
|
wire(catalog, { m1l1: makeBundle("m1l1", "v1") });
|
|
|
|
const res = await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
expect(res.exitCode).toBe(1);
|
|
const lessons = envelope(res.stdout).data.lessons as Array<{ lessonId: string; status: string }>;
|
|
expect(lessons.find((l) => l.lessonId === "m1l2")!.status).toBe("errored");
|
|
expect(lessons.find((l) => l.lessonId === "m1l1")!.status).not.toBe("errored");
|
|
});
|
|
});
|
|
|
|
describe("10x sync — locked modules", () => {
|
|
it("excludes locked-module lessons with a reason and does not fetch them", async () => {
|
|
const catalog = makeCatalog(
|
|
[
|
|
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "a" }),
|
|
lessonSummary({ lessonId: "m2l1", module: 2, lesson: 1, contentHash: "b" }),
|
|
],
|
|
[2],
|
|
);
|
|
wire(catalog, { m1l1: makeBundle("m1l1", "v1"), m2l1: makeBundle("m2l1", "v1") });
|
|
|
|
const res = await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
|
|
expect(fetched).toEqual(["m1l1"]); // m2l1 excluded, never fetched
|
|
const excluded = envelope(res.stdout).data.excluded as Array<{ lessonId: string; reason: string }>;
|
|
expect(excluded).toEqual([{ lessonId: "m2l1", reason: "module 2 is locked" }]);
|
|
});
|
|
});
|
|
|
|
describe("v4 operation release consistency", () => {
|
|
beforeEach(() => {
|
|
apiContentMockState.fetchCoursesImpl = () => ({ ok: true, status: 200, responseHeaders: new Headers(), rawBody: "", data: { courses: [{ id: "10xdevs-4", slug: "10xdevs4", title: "10xDevs 4", edition: 4, available: true }], defaultCourse: "10xdevs4" } });
|
|
});
|
|
it("sync reads one catalog and retains that release after current advances between lessons", async () => {
|
|
const release = { course: "10xdevs4", releaseId: `r-${"a".repeat(64)}`, releaseManifestHash: "b".repeat(64) };
|
|
const catalog = { ...makeCatalog([lessonSummary({lessonId:"m1l1",module:1,lesson:1}),lessonSummary({lessonId:"m1l2",module:1,lesson:2})]), ...release };
|
|
let catalogReads = 0;
|
|
const selections: unknown[] = [];
|
|
apiContentMockState.fetchCatalogImpl = () => { catalogReads++; return okCatalog(catalog); };
|
|
apiContentMockState.fetchLessonImpl = (_course,id,_token,options) => {
|
|
selections.push(options?.release);
|
|
return okLesson({ ...makeBundle(id, options?.release?.releaseId === release.releaseId ? "selected-old-bytes" : "new-current-bytes"), ...release });
|
|
};
|
|
const res = await runSyncCmd(["--all","--course","10xdevs4","--tool","claude-code"]);
|
|
expect(res.exitCode).toBeUndefined(); expect(envelope(res.stdout).status).toBe("ok");
|
|
expect(catalogReads).toBe(1); expect(selections).toEqual([release,release]);
|
|
expect(readFileSync(join(tmp,".claude/skills/auth-skill/SKILL.md"),"utf8")).toBe("selected-old-bytes");
|
|
});
|
|
it("printed get selects the catalog once and propagates release to individual artifact reads", async () => {
|
|
const release = { course:"10xdevs4", releaseId:`r-${"a".repeat(64)}`, releaseManifestHash:"b".repeat(64) };
|
|
let catalogReads=0; let selected:unknown;
|
|
apiContentMockState.fetchCatalogImpl=()=>{catalogReads++;return okCatalog({...makeCatalog([]),...release});};
|
|
apiContentMockState.fetchArtifactImpl=(_course,_lesson,_type,_name,_tool,_token,options)=>{selected=options?.release;return {ok:true,status:200,data:{type:"prompts",name:"hello",content:"selected artifact",...release},responseHeaders:new Headers(),rawBody:""};};
|
|
const res=await captureStreams(async()=>{const {runGet}=await import("../src/commands/get");const {resolveContext}=await import("../src/lib/output");await runGet(resolveContext({json:true}),"m1l1",{course:"10xdevs4",tool:"claude-code",print:true,type:"prompts",name:"hello"});});
|
|
expect(catalogReads).toBe(1);expect(selected).toEqual(release);expect(res.stdout).toContain("selected artifact");
|
|
});
|
|
});
|
|
|
|
describe("sync local state and representation", () => {
|
|
function seed() {
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "same-digest" })]), { m1l1: makeBundle("m1l1", "upstream") });
|
|
}
|
|
it("repairs missing files despite an unchanged catalog digest", async () => {
|
|
seed(); await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
rmSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"));
|
|
fetched = [];
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("upstream");
|
|
});
|
|
it("reports local edits even when upstream digest has not changed", async () => {
|
|
seed(); await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
writeFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "local");
|
|
fetched = [];
|
|
const response = await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
expect(response.stdout).toContain("skipped-conflict");
|
|
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("local");
|
|
});
|
|
it("changing language invalidates the digest shortcut and stores the delivered representation", async () => {
|
|
seed(); await runSyncCmd(["--all", "--tool", "claude-code", "--lang", "en"]);
|
|
fetched = [];
|
|
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "same-digest" })]), { m1l1: makeBundle("m1l1", "polska wersja") });
|
|
await runSyncCmd(["--tool", "claude-code", "--lang", "pl"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("polska wersja");
|
|
const state = readManifestFile() as { lessons: Record<string, { representation: { lang: string } }> };
|
|
expect(state.lessons.m1l1!.representation.lang).toBe("pl");
|
|
});
|
|
it("refetches old manifests without representation metadata", async () => {
|
|
seed(); await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
const state = readManifestFile() as { lessons: Record<string, { representation?: unknown }> };
|
|
delete state.lessons.m1l1!.representation;
|
|
writeFileSync(join(tmp, ".claude", MANIFEST_FILENAME), JSON.stringify(state));
|
|
fetched = [];
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
});
|
|
it("force still preserves edited managed rules and their upstream baseline", async () => {
|
|
const original = { ...makeBundle("m1l1", "upstream"), rules: [{ name: "rules", content: "original rule" }] };
|
|
const catalog = makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]);
|
|
wire(catalog, { m1l1: original });
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
const before = readManifestFile().managedRules;
|
|
const path = join(tmp, "CLAUDE.md");
|
|
const local = readFileSync(path, "utf8").replace("original rule", "my local rule");
|
|
writeFileSync(path, local);
|
|
const response = await runSyncCmd(["--force", "--tool", "claude-code"]);
|
|
expect(response.stdout).toContain("skipped-conflict");
|
|
expect(readFileSync(path, "utf8")).toBe(local);
|
|
expect(readManifestFile().managedRules).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("sync cumulative variants converge", () => {
|
|
function variants(secondId = "m1l2", firstId = "m1l1") {
|
|
const summaries = [firstId, secondId].map((id) => {
|
|
const [, module, lesson] = /^m(\d+)l(\d+)$/.exec(id)!;
|
|
return lessonSummary({ lessonId: id, module: Number(module), lesson: Number(lesson), contentHash: `digest-${id}` });
|
|
});
|
|
const bundles = Object.fromEntries(summaries.map(({ lessonId }) => {
|
|
const bundle = makeBundle(lessonId, `skill-${lessonId}`);
|
|
bundle.skills[0]!.files.push({ path: "references/shared.md", content: `reference-${lessonId}` });
|
|
bundle.prompts = [{ name: "shared", content: `prompt-${lessonId}` }];
|
|
bundle.rules = [{ name: "rules", content: `rules-${lessonId}` }];
|
|
return [lessonId, bundle];
|
|
}));
|
|
wire(makeCatalog(summaries), bundles);
|
|
return { summaries, bundles };
|
|
}
|
|
function skillPath() { return join(tmp, ".claude/skills/auth-skill/SKILL.md"); }
|
|
function assertFinal(secondId = "m1l2") {
|
|
expect(readFileSync(skillPath(), "utf8")).toBe(`skill-${secondId}`);
|
|
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/references/shared.md"), "utf8")).toBe(`reference-${secondId}`);
|
|
expect(readFileSync(join(tmp, ".claude/prompts/shared.md"), "utf8")).toBe(`prompt-${secondId}`);
|
|
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toContain(`rules-${secondId}`);
|
|
}
|
|
async function assertIdle() {
|
|
const paths = [skillPath(), join(tmp, ".claude/skills/auth-skill/references/shared.md"), join(tmp, ".claude/prompts/shared.md"), join(tmp, "CLAUDE.md"), join(tmp, ".claude", MANIFEST_FILENAME)];
|
|
const before = paths.map((path) => {
|
|
utimesSync(path, new Date("2001-01-01T00:00:00Z"), new Date("2001-01-01T00:00:00Z"));
|
|
return { bytes: readFileSync(path), mtime: statSync(path).mtimeMs };
|
|
});
|
|
fetched = [];
|
|
const response = await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(response.exitCode).toBeUndefined();
|
|
expect(envelope(response.stdout).data.totals).toMatchObject({ updated: 0, unchanged: 2, conflicts: 0, errored: 0, resources: { created: 0, upstreamUpdated: 0, removed: 0 } });
|
|
expect(fetched).toEqual([]);
|
|
paths.forEach((path, index) => {
|
|
expect(readFileSync(path)).toEqual(before[index]!.bytes);
|
|
expect(statSync(path).mtimeMs).toBe(before[index]!.mtime);
|
|
});
|
|
}
|
|
it("repeated sync of distinct cumulative skill, support, prompt and rules variants has zero writes", async () => {
|
|
variants();
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
assertFinal();
|
|
await assertIdle();
|
|
await assertIdle();
|
|
});
|
|
it("orders supersession numerically when lesson numbers have different widths", async () => {
|
|
variants("m1l10", "m1l2");
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
assertFinal("m1l10");
|
|
await assertIdle();
|
|
});
|
|
it("an earlier upstream change still reapplies the later owner before becoming idle", async () => {
|
|
const { summaries, bundles } = variants();
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
summaries[0]!.contentHash = "earlier-new-digest";
|
|
bundles.m1l1!.skills[0]!.files[0]!.content = "earlier new upstream";
|
|
wire(makeCatalog(summaries), bundles);
|
|
fetched = [];
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1", "m1l2"]);
|
|
assertFinal();
|
|
await assertIdle();
|
|
});
|
|
it("module filtering preserves selected scope and a later full sync restores later ownership", async () => {
|
|
const { summaries, bundles } = variants("m2l1");
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
summaries[0]!.contentHash = "earlier-new-digest";
|
|
bundles.m1l1!.skills[0]!.files[0]!.content = "earlier new upstream";
|
|
wire(makeCatalog(summaries), bundles);
|
|
fetched = [];
|
|
await runSyncCmd(["--module", "m1", "--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
expect(readFileSync(skillPath(), "utf8")).toBe("earlier new upstream");
|
|
fetched = [];
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m2l1"]);
|
|
assertFinal("m2l1");
|
|
await assertIdle();
|
|
});
|
|
it("repairs a missing shared support file with the final owner's exact bytes", async () => {
|
|
variants();
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
rmSync(join(tmp, ".claude/skills/auth-skill/references/shared.md"));
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
assertFinal();
|
|
await assertIdle();
|
|
});
|
|
it("preserves local shared edits despite matching digests", async () => {
|
|
variants();
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
writeFileSync(skillPath(), "my local shared edit");
|
|
fetched = [];
|
|
const response = await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1", "m1l2"]);
|
|
expect(response.stdout).toContain("skipped-conflict");
|
|
expect(readFileSync(skillPath(), "utf8")).toBe("my local shared edit");
|
|
});
|
|
it("a module-scoped language change invalidates owners in the previous language", async () => {
|
|
const { summaries, bundles } = variants("m2l1");
|
|
await runSyncCmd(["--all", "--tool", "claude-code", "--lang", "en"]);
|
|
bundles.m2l1!.skills[0]!.files[0]!.content = "polski wariant";
|
|
wire(makeCatalog(summaries), bundles);
|
|
await runSyncCmd(["--module", "m2", "--tool", "claude-code", "--lang", "pl"]);
|
|
expect(readFileSync(skillPath(), "utf8")).toBe("polski wariant");
|
|
fetched = [];
|
|
await runSyncCmd(["--module", "m1", "--tool", "claude-code", "--lang", "en"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
expect(readFileSync(skillPath(), "utf8")).toBe("skill-m1l1");
|
|
});
|
|
it("a later lesson with no rules retains the earlier managed block without repeated writes", async () => {
|
|
const { summaries, bundles } = variants();
|
|
bundles.m1l2!.rules = [];
|
|
wire(makeCatalog(summaries), bundles);
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toContain("rules-m1l1");
|
|
await assertIdle();
|
|
});
|
|
it("a partial later write cannot certify a complete cumulative representation", async () => {
|
|
variants();
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
const { applyBundle } = await import("../src/lib/writer");
|
|
await applyBundle(makeBundle("m1l2", "partial later variant"), tmp, { partial: true });
|
|
expect(readFileSync(skillPath(), "utf8")).toBe("partial later variant");
|
|
fetched = [];
|
|
await runSyncCmd(["--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1", "m1l2"]);
|
|
assertFinal();
|
|
await assertIdle();
|
|
});
|
|
it("a module-scoped rules opt-out does not preserve earlier owners' enabled-policy shortcut", async () => {
|
|
variants("m2l1");
|
|
await runSyncCmd(["--all", "--tool", "claude-code"]);
|
|
const argv = process.argv;
|
|
try {
|
|
process.argv = [...argv, "--no-course-rules"];
|
|
await runSyncCmd(["--module", "m2", "--tool", "claude-code"]);
|
|
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).not.toContain("rules-m2l1");
|
|
process.argv = [...argv, "--course-rules"];
|
|
fetched = [];
|
|
await runSyncCmd(["--module", "m1", "--tool", "claude-code"]);
|
|
expect(fetched).toEqual(["m1l1"]);
|
|
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toContain("rules-m1l1");
|
|
} finally { process.argv = argv; }
|
|
});
|
|
});
|