feat(bulk-sync-update): 10x sync command (p3)

- commands/sync.ts: new `10x sync` — enumerate, cheap-skip via catalog digest,
  fetch+apply, actionable report (per-resource remediation), worst-outcome exit
- index.ts: register sync command
- manifest.ts: LessonFilesEntry.catalogContentHash (digest-vs-digest skip), tolerant read
- writer.ts: applyBundle persists catalogContentHash (carried forward on plain get)
- tests/sync-command.test.ts: --all, default/--module, cheap-skip, changed, dry-run,
  conflict+remediation, --force, partial-failure exit 1, locked exclusion, digest round-trip
This commit is contained in:
“mkczarkowski”
2026-06-20 11:37:51 +02:00
parent ca4d0bf247
commit ec8a2a644d
6 changed files with 1137 additions and 13 deletions
+13 -13
View File
@@ -487,10 +487,10 @@ fine and simply always-fetch until the next apply records a digest. No data migr
### Phase 2: CLI — planBundle() writer refactor + types
#### Automated
- [x] 2.1 Typecheck passes
- [x] 2.2 Lint passes
- [x] 2.3 Writer tests pass, including planBundle parity tests
- [x] 2.4 Regenerated api-types.ts contains contentHash on the catalog lesson type
- [x] 2.1 Typecheck passes — ca4d0bf
- [x] 2.2 Lint passes — ca4d0bf
- [x] 2.3 Writer tests pass, including planBundle parity tests — ca4d0bf
- [x] 2.4 Regenerated api-types.ts contains contentHash on the catalog lesson type — ca4d0bf
#### Manual
- [ ] 2.5 planBundle output matches applyBundle actions across clean/unchanged/conflict/new
@@ -499,15 +499,15 @@ fine and simply always-fetch until the next apply records a digest. No data migr
### Phase 3: CLI — the 10x sync command
#### Automated
- [ ] 3.1 Typecheck + lint pass
- [ ] 3.2 `10x sync --all` downloads all unlocked lessons
- [ ] 3.3 Default sync targets only manifest.lessons; --module filters
- [ ] 3.4 Cheap-skip: matching catalog contentHash → lesson NOT fetched
- [ ] 3.5 Changed lesson (digest differs) is fetched and applied
- [ ] 3.6 --dry-run writes nothing and still reports the plan
- [ ] 3.7 Conflict default → skipped-conflict with remediation command; --force → overwrite
- [ ] 3.8 Partial failure → exit code 1, full report still emitted
- [ ] 3.9 Locked-module lessons excluded with a reason
- [x] 3.1 Typecheck + lint pass
- [x] 3.2 `10x sync --all` downloads all unlocked lessons
- [x] 3.3 Default sync targets only manifest.lessons; --module filters
- [x] 3.4 Cheap-skip: matching catalog contentHash → lesson NOT fetched
- [x] 3.5 Changed lesson (digest differs) is fetched and applied
- [x] 3.6 --dry-run writes nothing and still reports the plan
- [x] 3.7 Conflict default → skipped-conflict with remediation command; --force → overwrite
- [x] 3.8 Partial failure → exit code 1, full report still emitted
- [x] 3.9 Locked-module lessons excluded with a reason
#### Manual
- [ ] 3.10 Real sync --all against local API populates project in correct order
+644
View File
@@ -0,0 +1,644 @@
/**
* `10x sync` — bulk download + update with change visibility.
*
* Enumerates a course's unlocked lessons in one catalog call, cheap-skips
* lessons whose upstream is unchanged (per-lesson `contentHash` vs the digest
* stored at last apply — no download), fetches + applies the rest, and emits a
* single actionable report. Every resource that was NOT updated tells the user
* the exact command to take it. Exit code is worst-outcome: 1 if any lesson
* errored, otherwise 0 (a skipped conflict is reported, not a failure).
*
* Unlike `get`, sync NEVER prompts (it sweeps many lessons non-interactively)
* and NEVER `process.exit`s mid-loop — per-lesson failures accumulate into the
* report and only set the final exit code.
*/
import { join } from "node:path";
import type { CAC } from "cac";
import {
fetchCatalog,
fetchLesson,
type LessonBundle,
type LessonSummary,
} from "../lib/api-content";
import { requireAuth } from "../lib/auth-guard";
import { type CliManifest, readManifest } from "../lib/manifest";
import {
ExitCodes,
type GlobalFlags,
type OutputContext,
output,
outputError,
resolveContext,
verbose,
} from "../lib/output";
import { readToolConfig } from "../lib/config";
import { resolveToolProfile } from "../lib/tool-prompt";
import type { ToolProfile } from "../lib/tool-profile";
import {
applyBundle,
type ArtifactAction,
type ConflictResolver,
planBundle,
type WritePlan,
type WriteResult,
} from "../lib/writer";
import { resolveCourseRulesFlag } from "./get";
/** Default course slug. Hardcoded for v1 per plan; configurable later. */
const DEFAULT_COURSE = "10xdevs3";
const SUPPORTED_LANGS = ["en", "pl"];
interface SyncFlags extends GlobalFlags {
all?: boolean;
dryRun?: boolean;
force?: boolean;
module?: string;
course?: string;
tool?: string;
lang?: string;
courseRules?: boolean;
}
type ArtifactKind = "skills" | "prompts" | "rules" | "configs";
type Bucket = "created" | "upstream-updated" | "unchanged" | "skipped-conflict" | "removed";
interface ResourceOutcome {
type: ArtifactKind;
name: string;
/** Relative file path within a skill (skills only). */
file?: string;
bucket: Bucket;
/** A copy-pasteable command to take this update, for skipped-conflict. */
remediation?: string;
}
type LessonStatus = "updated" | "unchanged" | "conflicts" | "errored";
interface LessonOutcome {
lessonId: string;
title: string;
fetched: boolean;
status: LessonStatus;
resources: ResourceOutcome[];
error?: { code: string; message: string; retry: string };
}
interface Exclusion {
lessonId: string;
reason: string;
}
export function registerSyncCommand(cli: CAC): void {
cli
.command("sync", "Bulk-download and update lessons, reporting what changed upstream")
.option("--all", "Sync every unlocked lesson (default: only lessons you've already downloaded)")
.option("--dry-run", "Show what would change without writing")
.option("--force", "Ignore the cheap-skip digest and overwrite local edits with upstream")
.option("--module <module>", "Limit to one module (e.g. 'm2' or '2')")
.option("--course <course>", "Override the course slug (default: 10xdevs3)")
.option(
"--tool <tool>",
"AI coding tool (claude-code, cursor, copilot, codex, windsurf, gemini, generic)",
)
.option("--lang <lang>", "Content language: en (default) or pl")
.option(
"--no-course-rules",
"Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)",
)
.action(async (options: SyncFlags) => {
const ctx = resolveContext(options);
await runSync(ctx, options);
});
}
export async function runSync(ctx: OutputContext, options: SyncFlags): Promise<void> {
if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
outputError(
ctx,
"invalid_lang",
`Unknown language '${options.lang}'.`,
ExitCodes.USAGE,
`Supported languages: ${SUPPORTED_LANGS.join(", ")}`,
);
}
let moduleFilter: number | undefined;
if (options.module !== undefined) {
const parsed = parseModule(options.module);
if (parsed === null) {
outputError(
ctx,
"invalid_module",
`'${options.module}' is not a valid module.`,
ExitCodes.USAGE,
"Use a module number like '2' or 'm2'.",
);
}
moduleFilter = parsed;
}
const auth = await requireAuth(ctx);
const course = options.course ?? DEFAULT_COURSE;
const profile = await resolveToolProfile(options.tool, process.cwd());
const dryRun = options.dryRun === true;
const force = options.force === true;
const lang = options.lang ?? readToolConfig()?.lang ?? "en";
const explicitCourseRules = resolveCourseRulesFlag(process.argv);
const applyCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
verbose(ctx, `fetching catalog ${course}`);
const catalogResult = await fetchCatalog(course, auth.access_token);
if (!catalogResult.ok) {
handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
}
const catalog = catalogResult.data;
// Module effective state drives which lessons are reachable. The catalog only
// returns unlocked lessons, but we filter defensively + record any locked one.
const moduleState = new Map<number, "locked" | "unlocked">();
for (const mod of catalog.modules) moduleState.set(mod.module, mod.effectiveState);
const exclusions: Exclusion[] = [];
const unlocked = catalog.lessons.filter((lesson) => {
if (moduleState.get(lesson.module) === "locked") {
exclusions.push({ lessonId: lesson.lessonId, reason: `module ${lesson.module} is locked` });
return false;
}
return true;
});
const manifest = readManifest(join(process.cwd(), profile.manifestDir));
const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : []);
let targets = unlocked;
if (moduleFilter !== undefined) targets = targets.filter((l) => l.module === moduleFilter);
// Default targets only already-downloaded lessons; --all pulls everything.
if (options.all !== true) targets = targets.filter((l) => manifestLessonIds.has(l.lessonId));
targets = [...targets].sort((a, b) => a.module - b.module || a.lesson - b.lesson);
// Sequential sweep sharing one AbortSignal — no retry framework, the existing
// per-call timeout stands. Never process.exit mid-loop.
const controller = new AbortController();
const outcomes: LessonOutcome[] = [];
for (const lesson of targets) {
outcomes.push(
await syncLesson(ctx, lesson, {
course,
profile,
lang,
dryRun,
force,
applyCourseRules,
token: auth.access_token,
manifest,
signal: controller.signal,
}),
);
}
renderReport(ctx, profile, {
course,
dryRun,
force,
mode: options.all === true ? "all" : "downloaded",
module: moduleFilter,
outcomes,
exclusions,
});
// Worst-outcome exit code. Conflicts-skipped alone stays 0 (reported, not a
// failure); any errored lesson → 1.
if (outcomes.some((o) => o.status === "errored")) {
process.exit(ExitCodes.ERROR);
}
}
interface SyncLessonOpts {
course: string;
profile: ToolProfile;
lang: string;
dryRun: boolean;
force: boolean;
applyCourseRules: boolean;
token: string;
manifest: CliManifest | null;
signal: AbortSignal;
}
async function syncLesson(
ctx: OutputContext,
lesson: LessonSummary,
opts: SyncLessonOpts,
): Promise<LessonOutcome> {
const stored = opts.manifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
// Cheap-skip: digest-vs-digest. Only when NOT --force, the catalog advertises
// a digest, AND we have one stored from last apply. Otherwise fall through to
// a real fetch (the always-fetch fallback for older backends/manifests).
if (
!opts.force &&
lesson.contentHash !== undefined &&
stored !== undefined &&
lesson.contentHash === stored
) {
verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
return {
lessonId: lesson.lessonId,
title: lesson.title,
fetched: false,
status: "unchanged",
resources: [],
};
}
verbose(ctx, `${lesson.lessonId}: fetching`);
const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
lang: opts.lang,
tool: opts.profile.toolId,
signal: opts.signal,
});
if (!result.ok) {
return {
lessonId: lesson.lessonId,
title: lesson.title,
fetched: true,
status: "errored",
resources: [],
error: {
code: result.code || "lesson_fetch_failed",
message: lessonErrorMessage(result.status, result.code, result.error),
retry: `10x get ${lesson.lessonId}`,
},
};
}
const bundle: LessonBundle = result.data;
if (opts.dryRun) {
const plan = planBundle(bundle, process.cwd(), {
profile: opts.profile,
applyCourseRules: opts.applyCourseRules,
});
const resources = classifyFromPlan(plan, lesson.lessonId, opts.force);
return {
lessonId: lesson.lessonId,
title: lesson.title,
fetched: true,
status: lessonStatus(resources),
resources,
};
}
// Non-interactive resolver: default skips (user work preserved), --force
// overwrites. The cheap-skip gate was already bypassed above when --force.
const onConflict: ConflictResolver = opts.force
? async () => "overwrite"
: async () => "skip";
const writeResult = await applyBundle(bundle, process.cwd(), {
course: opts.course,
profile: opts.profile,
onConflict,
applyCourseRules: opts.applyCourseRules,
catalogContentHash: lesson.contentHash,
});
const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
return {
lessonId: lesson.lessonId,
title: lesson.title,
fetched: true,
status: lessonStatus(resources),
resources,
};
}
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
function actionToBucket(action: ArtifactAction): Bucket {
switch (action) {
case "created":
return "created";
case "updated":
case "conflict_overwritten":
case "conflict_saved_user":
return "upstream-updated";
case "conflict_skipped":
return "skipped-conflict";
case "removed":
return "removed";
case "unchanged":
case "skipped":
default:
return "unchanged";
}
}
function remediation(lessonId: string, type: ArtifactKind, name: string): string {
return `10x get ${lessonId} --type ${type} --name ${name}`;
}
function classifyFromWriteResult(result: WriteResult, lessonId: string): ResourceOutcome[] {
const out: ResourceOutcome[] = [];
for (const skill of result.skills) {
for (const file of skill.files) {
const bucket = actionToBucket(file.action);
out.push({
type: "skills",
name: skill.name,
file: file.path,
bucket,
...(bucket === "skipped-conflict"
? { remediation: remediation(lessonId, "skills", skill.name) }
: {}),
});
}
}
for (const prompt of result.prompts) {
const bucket = actionToBucket(prompt.action);
out.push({
type: "prompts",
name: prompt.name,
bucket,
...(bucket === "skipped-conflict"
? { remediation: remediation(lessonId, "prompts", prompt.name) }
: {}),
});
}
out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action) });
for (const config of result.configs) {
out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
}
for (const entry of result.removals.skills) {
out.push({ type: "skills", name: entry.name, bucket: "removed" });
}
for (const entry of result.removals.prompts) {
out.push({ type: "prompts", name: entry.name, bucket: "removed" });
}
for (const entry of result.removals.configs) {
out.push({ type: "configs", name: entry.name, bucket: "removed" });
}
return out;
}
function classifyFromPlan(plan: WritePlan, lessonId: string, force: boolean): ResourceOutcome[] {
const out: ResourceOutcome[] = [];
// A conflict in a dry-run reads as skipped-conflict by default; with --force
// it would overwrite, so report it as an upstream update instead.
const conflictBucket: Bucket = force ? "upstream-updated" : "skipped-conflict";
for (const skill of plan.skills) {
for (const file of skill.files) {
const bucket = file.isConflict ? conflictBucket : actionToBucket(file.action);
out.push({
type: "skills",
name: skill.name,
file: file.relativePath,
bucket,
...(bucket === "skipped-conflict"
? { remediation: remediation(lessonId, "skills", skill.name) }
: {}),
});
}
}
for (const prompt of plan.prompts) {
const bucket = prompt.isConflict ? conflictBucket : actionToBucket(prompt.action);
out.push({
type: "prompts",
name: prompt.name,
bucket,
...(bucket === "skipped-conflict"
? { remediation: remediation(lessonId, "prompts", prompt.name) }
: {}),
});
}
out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(plan.rules.action) });
for (const config of plan.configs) {
out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
}
for (const entry of plan.removals.skills) {
out.push({ type: "skills", name: entry.name, bucket: "removed" });
}
for (const entry of plan.removals.prompts) {
out.push({ type: "prompts", name: entry.name, bucket: "removed" });
}
for (const entry of plan.removals.configs) {
out.push({ type: "configs", name: entry.name, bucket: "removed" });
}
return out;
}
function lessonStatus(resources: ResourceOutcome[]): LessonStatus {
if (resources.some((r) => r.bucket === "created" || r.bucket === "upstream-updated" || r.bucket === "removed")) {
return "updated";
}
if (resources.some((r) => r.bucket === "skipped-conflict")) return "conflicts";
return "unchanged";
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
interface ReportInput {
course: string;
dryRun: boolean;
force: boolean;
mode: "all" | "downloaded";
module: number | undefined;
outcomes: LessonOutcome[];
exclusions: Exclusion[];
}
function countBuckets(outcomes: LessonOutcome[]): Record<Bucket, number> {
const totals: Record<Bucket, number> = {
created: 0,
"upstream-updated": 0,
unchanged: 0,
"skipped-conflict": 0,
removed: 0,
};
for (const o of outcomes) {
for (const r of o.resources) totals[r.bucket]++;
}
return totals;
}
function renderReport(ctx: OutputContext, profile: ToolProfile, input: ReportInput): void {
const { outcomes, exclusions } = input;
const buckets = countBuckets(outcomes);
const lessonsErrored = outcomes.filter((o) => o.status === "errored").length;
const lessonsConflicts = outcomes.filter((o) => o.status === "conflicts").length;
if (ctx.json) {
output(ctx, "", {
course: input.course,
tool: profile.toolId,
dryRun: input.dryRun,
force: input.force,
mode: input.mode,
module: input.module ?? null,
lessons: outcomes,
excluded: exclusions,
totals: {
lessons: outcomes.length,
updated: outcomes.filter((o) => o.status === "updated").length,
unchanged: outcomes.filter((o) => o.status === "unchanged").length,
conflicts: lessonsConflicts,
errored: lessonsErrored,
excluded: exclusions.length,
resources: {
created: buckets.created,
upstreamUpdated: buckets["upstream-updated"],
unchanged: buckets.unchanged,
skippedConflict: buckets["skipped-conflict"],
removed: buckets.removed,
},
},
});
return;
}
const lines: string[] = [];
const verb = input.dryRun ? "Would sync" : "Synced";
const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
lines.push(`${verb} ${input.course}${scope}${moduleNote}:`);
if (outcomes.length === 0) {
lines.push(" (nothing to sync)");
}
for (const o of outcomes) {
if (o.status === "errored") {
lines.push(` ${o.lessonId} — error: ${o.error?.message ?? "failed"}`);
if (o.error) lines.push(` → retry: ${o.error.retry}`);
continue;
}
const summary = summarizeLesson(o);
lines.push(` ${o.lessonId}${o.status}${summary ? ` (${summary})` : ""}`);
for (const r of o.resources) {
if (r.bucket !== "skipped-conflict") continue;
const label = r.file ? `${r.type}/${r.name} (${r.file})` : `${r.type}/${r.name}`;
lines.push(` skipped ${label} — you edited it → ${r.remediation}`);
}
}
if (exclusions.length > 0) {
lines.push("");
lines.push("Excluded:");
for (const ex of exclusions) lines.push(` ${ex.lessonId}${ex.reason}`);
}
lines.push("");
lines.push(
`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` +
`${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` +
`${buckets.removed} removed.`,
);
if (lessonsConflicts > 0 && !input.force) {
lines.push("To take all upstream updates over your local edits: 10x sync --force");
}
output(ctx, lines.join("\n"), undefined);
}
function summarizeLesson(o: LessonOutcome): string {
const counts: Record<Bucket, number> = {
created: 0,
"upstream-updated": 0,
unchanged: 0,
"skipped-conflict": 0,
removed: 0,
};
for (const r of o.resources) counts[r.bucket]++;
const parts: string[] = [];
if (counts["upstream-updated"]) parts.push(`${counts["upstream-updated"]} updated`);
if (counts.created) parts.push(`${counts.created} new`);
if (counts["skipped-conflict"]) parts.push(`${counts["skipped-conflict"]} skipped`);
if (counts.removed) parts.push(`${counts.removed} removed`);
if (!o.fetched) parts.push("not downloaded");
return parts.join(", ");
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/** Hard catalog failure — the whole sweep can't proceed, so this exits. */
function handleCatalogError(
ctx: OutputContext,
status: number,
code: string,
error: string,
): never {
if (status === 401) {
outputError(
ctx,
"auth_required",
"Your session is no longer valid.",
ExitCodes.AUTH_REQUIRED,
"Run '10x auth' to log in again.",
);
}
if (status === 404) {
outputError(
ctx,
"course_not_found",
"Couldn't find that course.",
ExitCodes.NOT_FOUND,
"Check the --course slug, or omit it to use the default.",
);
}
if (status === 0) {
outputError(
ctx,
"network_error",
"Could not reach the 10x-toolkit API.",
ExitCodes.ERROR,
"Check your internet connection and run the command again.",
);
}
outputError(
ctx,
code || "catalog_fetch_failed",
"Failed to fetch the course catalog.",
ExitCodes.ERROR,
error ? `Server said: ${error}` : undefined,
);
}
/** Per-lesson error message — NO process.exit (the sweep continues). */
function lessonErrorMessage(status: number, code: string, error: string): string {
if (status === 403) return "Module is locked.";
if (status === 404) return "Lesson not found.";
if (status === 401) return "Session expired.";
if (status === 0) return "Network error reaching the API.";
if (code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
return "Bundle signature verification failed.";
}
return error ? `Fetch failed: ${error}` : "Fetch failed.";
}
function parseModule(value: string): number | null {
const match = /^m?(\d+)$/i.exec(value.trim());
if (!match) return null;
return Number(match[1]);
}
+2
View File
@@ -5,6 +5,7 @@ import { registerAuthCommand } from "./commands/auth";
import { registerDoctorCommand } from "./commands/doctor";
import { registerGetCommand } from "./commands/get";
import { registerListCommand } from "./commands/list";
import { registerSyncCommand } from "./commands/sync";
const cli = cac("10x");
@@ -14,6 +15,7 @@ cli.option("--verbose", "Show detailed output on stderr");
registerAuthCommand(cli);
registerGetCommand(cli);
registerListCommand(cli);
registerSyncCommand(cli);
registerDoctorCommand(cli);
cli.help();
+10
View File
@@ -33,6 +33,13 @@ export interface LessonFilesEntry {
skills: Record<string, { files: string[] }>;
prompts: string[];
configs: string[];
/**
* The catalog's per-lesson `contentHash` current when this lesson was last
* applied. `10x sync` compares the catalog's new digest against this stored
* one (digest-vs-digest) to skip unchanged lessons without downloading. Additive
* + optional: older manifests omit it → sync always-fetches that lesson once.
*/
catalogContentHash?: string;
}
export interface CliManifest {
@@ -144,6 +151,9 @@ function isLessonFilesEntry(value: unknown): value is LessonFilesEntry {
if (typeof skill !== "object" || skill === null) return false;
if (!isStringArray((skill as Record<string, unknown>)["files"])) return false;
}
if (e["catalogContentHash"] !== undefined && typeof e["catalogContentHash"] !== "string") {
return false;
}
return isStringArray(e["prompts"]) && isStringArray(e["configs"]);
}
+15
View File
@@ -201,6 +201,14 @@ export interface ApplyOptions {
* rules file (surrounding content preserved).
*/
applyCourseRules?: boolean;
/**
* The catalog's per-lesson `contentHash` for this lesson, recorded into the
* manifest so the next `10x sync` can skip the lesson when upstream is
* unchanged (digest-vs-digest). When omitted, any previously stored digest
* for this lesson is preserved (so a plain `get` neither refreshes nor erases
* it). Ignored under `dryRun`/`partial` (no manifest write).
*/
catalogContentHash?: string;
}
/**
@@ -397,6 +405,12 @@ export async function applyBundle(
// --- manifest ---------------------------------------------------------
if (!dryRun && !partial) {
// Preserve a previously stored catalog digest when this apply didn't supply
// one (e.g. a plain `get`), so it neither refreshes nor erases what `sync`
// recorded — at worst one redundant fetch never happens.
const catalogContentHash =
options.catalogContentHash ?? prevManifest?.lessons?.[bundle.lessonId]?.catalogContentHash;
const newLessonEntry: LessonFilesEntry = {
appliedAt: new Date().toISOString(),
skills: Object.fromEntries(
@@ -404,6 +418,7 @@ export async function applyBundle(
),
prompts: bundle.prompts.map((p) => `${p.name}.md`),
configs: bundle.configs.map((c) => c.name),
...(catalogContentHash !== undefined ? { catalogContentHash } : {}),
};
// Seed lessons from previous manifest if it lacks per-lesson tracking
+453
View File
@@ -0,0 +1,453 @@
/**
* 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, 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" }]);
});
});