mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user