diff --git a/README.md b/README.md index 529ff6d..68214d5 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,16 @@ project's baseline. It describes toolkit releases, not your files: to see what a sync would actually download or update in this project, run `10x sync --dry-run`. It needs a login and never writes anything. +The changelog follows the project's course, the same one `get` and `sync` use +(`.10x-cli.json`, or the course in a tool manifest): a 10xDevs 3.0 project lists only +releases that reached 3.0, even when your account also has 4.0. Outside a project it +lists the releases of every course you have. A project whose files disagree on the +course stops with `course_binding_conflict`, as `get`/`sync` do. + The baseline is recorded per project: after a successful `10x sync` (not `--dry-run`, no failed lessons) or a full, unfiltered `10x get`, the CLI stores the newest -toolkit release version in the project manifest (`toolkit.version`). If the +toolkit release of the project's course in the project manifest (`toolkit.version`), or +removes a stored one when that course has no published release. If the backend cannot answer, nothing is recorded and the sync/get result is unchanged. Without a recorded version, `10x changelog` falls back to the date of the last lesson applied in the project; in a project with no manifest it shows the latest 5 @@ -298,6 +305,7 @@ releases and suggests running `10x sync`. | Flag | Description | |------|-------------| | `--since ` | Ignore the baseline: `vX.Y.Z` lists releases **after** that version; `YYYY-MM-DD` lists releases **from** the start of that UTC day | +| `--course ` | Show one course's releases instead of this project's course (e.g. `10xdevs4`) | | `--limit ` | Maximum number of releases, 1-100 (default 20; 5 without a baseline) | ```bash @@ -316,11 +324,11 @@ and the changed skills, prompts, rules and config templates with a status mark (`+` new, `~` changed, `-` removed, `>` renamed) and a one-line summary. Colours are used only on an interactive terminal and respect `NO_COLOR`. Exit code is `0` whether or not there are new releases; an invalid `--since`/`--limit` exits `2`, a -missing login `3`, and an account without course access `4`. Against +missing login `3`, and an account without access to the course `4`. Against a backend that does not serve the changelog yet the command fails with `changelog_unsupported` (exit `1`); `get` and `sync` keep working. JSON output (`--json` or piped) is `{ baseline: { source, version?, date?, recordedAt? }, -newEntries, entries }`, where `source` is `flag`, `sync`, `applied` or `none`. +course, newEntries, entries }` (`course` is `null` outside a project), where `source` is `flag`, `sync`, `applied` or `none`. ### `10x bench` diff --git a/package.json b/package.json index 9b181b0..42a47bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@przeprogramowani/10x-cli", - "version": "1.29.1", + "version": "1.30.0", "description": "Open-source CLI for 10xDevs course content", "repository": { "type": "git", diff --git a/skills/10x-cli-guide/SKILL.md b/skills/10x-cli-guide/SKILL.md index 48a0308..56f8dc4 100644 --- a/skills/10x-cli-guide/SKILL.md +++ b/skills/10x-cli-guide/SKILL.md @@ -222,6 +222,7 @@ is **unreleased**: check the actual runner's `changelog --help` first. ```bash 10x_cli changelog # toolkit releases since this project's last sync 10x_cli changelog --since v2.55.0 # releases after a version +10x_cli changelog --course 10xdevs4 # another course than this project's 10x_cli changelog --since 2026-10-01 # releases from that UTC day on ``` diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 572c343..4de3a07 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -9,6 +9,10 @@ * 3. newest lesson `appliedAt` (or `lastApplied`) as `sinceDate` * 4. no manifest → the latest 5 entries, with a hint to run `10x sync` * + * Course scope: `--course`, else the project's course (`.10x-cli.json` or a + * tool manifest, as get/sync read it), else every course the account holds. + * Grants only authorize; they never widen a project's scope. + * * Never prompts and never writes: the manifest is located from the configured * tool profile (falling back to the first profile with a readable manifest). */ @@ -20,6 +24,7 @@ import { requireAuth } from "../lib/auth-guard"; import { readToolConfig } from "../lib/config"; import { paint, STYLE, wrapText } from "../lib/format"; import { type CliManifest, readManifest } from "../lib/manifest"; +import { CourseBindingError, inspectProjectCourse } from "../lib/project-course"; import { ExitCodes, type GlobalFlags, @@ -34,6 +39,7 @@ import { DEFAULT_TOOL, getToolProfile, PROFILES, type ToolProfile } from "../lib interface ChangelogFlags extends GlobalFlags { since?: unknown; + course?: unknown; limit?: unknown; } @@ -58,6 +64,7 @@ export function registerChangelogCommand(cli: CAC): void { cli .command("changelog", "Show 10x-toolkit changes since your last sync") .option("--since ", "Show changes after a version (vX.Y.Z) or from a date (YYYY-MM-DD)") + .option("--course ", "Show one course's releases (default: this project's course)") .option("--limit ", `Maximum number of entries, 1-${MAX_LIMIT} (default: ${DEFAULT_CHANGELOG_LIMIT})`) .action(async (options: ChangelogFlags) => { const ctx = resolveContext(options); @@ -69,20 +76,43 @@ export async function runChangelog(ctx: OutputContext, options: ChangelogFlags): // Validate usage before touching credentials so bad invocations exit 2. const flagBaseline = options.since === undefined ? null : parseSinceFlag(ctx, options.since); const explicitLimit = options.limit === undefined ? undefined : parseLimitFlag(ctx, options.limit); + const scope = resolveCourseScope(ctx, options.course, process.cwd()); const auth = await requireAuth(ctx); const baseline = flagBaseline ?? resolveManifestBaseline(ctx, process.cwd()); const limit = explicitLimit ?? (baseline.source === "none" ? NO_BASELINE_LIMIT : DEFAULT_CHANGELOG_LIMIT); const query: ChangelogQuery = { limit }; + if (scope.course !== undefined) query.course = scope.course; if (baseline.version !== undefined) query.since = baseline.version; else if (baseline.date !== undefined) query.sinceDate = baseline.date; verbose(ctx, `fetching changelog ${JSON.stringify(query)}`); const result = await fetchChangelog(auth.access_token, query); - if (!result.ok) handleChangelogError(ctx, result.status, result.code, result.error); + if (!result.ok) handleChangelogError(ctx, result.status, result.code, result.error, scope); + + render(ctx, baseline, scope, result.data.entries); +} + +export interface CourseScope { + course?: string; + source: "flag" | "project" | "none"; +} - render(ctx, baseline, result.data.entries); +/** `--course`, else the project's course; a corrupt or conflicting project stops here, as in get/sync. */ +function resolveCourseScope(ctx: OutputContext, raw: unknown, projectRoot: string): CourseScope { + if (raw !== undefined) { + const value = typeof raw === "string" ? raw.trim() : ""; + if (!value) outputError(ctx, "invalid_course", "--course needs a course, e.g. '10xdevs4'.", ExitCodes.USAGE); + return { course: value, source: "flag" }; + } + try { + const { course } = inspectProjectCourse(projectRoot); + return course ? { course, source: "project" } : { source: "none" }; + } catch (error) { + if (error instanceof CourseBindingError) outputError(ctx, error.code, error.message, ExitCodes.ERROR); + throw error; + } } function parseSinceFlag(ctx: OutputContext, raw: unknown): ChangelogBaseline { @@ -166,7 +196,7 @@ function newestAppliedAt(manifest: CliManifest): string | undefined { return newest === undefined ? undefined : new Date(newest).toISOString(); } -function handleChangelogError(ctx: OutputContext, status: number, code: string, error: string): never { +function handleChangelogError(ctx: OutputContext, status: number, code: string, error: string, scope: CourseScope): never { if (code === "changelog_unsupported") { outputError( ctx, @@ -180,10 +210,16 @@ function handleChangelogError(ctx: OutputContext, status: number, code: string, outputError( ctx, "course_access_denied", - "Your account has no active course access, so there is no toolkit changelog to show.", + scope.course + ? `Your account does not have access to ${scope.course}, so there is no toolkit changelog to show for it.` + : "Your account has no active course access, so there is no toolkit changelog to show.", ExitCodes.FORBIDDEN, + scope.source === "project" ? `This project uses ${scope.course}; its changelog follows that course.` : undefined, ); } + if (status === 404 && code === "course_not_found") { + outputError(ctx, "course_not_found", `Unknown course: ${scope.course}.`, ExitCodes.USAGE, "Use a course id such as '10xdevs4'."); + } if (status === 401) { outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again."); } @@ -203,17 +239,21 @@ const day = (iso: string | undefined) => (iso ?? "").slice(0, 10); const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; /** Where this project stands, before any entries. */ -function header(baseline: ChangelogBaseline, count: number): string { +function header(baseline: ChangelogBaseline, scope: CourseScope, count: number): string { const v = paint(STYLE.bold, baseline.version ?? ""); + const course = scope.course ? paint(STYLE.bold, scope.course) : ""; + const project = course ? `this ${course} project` : "this project"; switch (baseline.source) { case "sync": - return `This project is on toolkit ${v} ${paint(STYLE.dim, `(recorded by 10x sync on ${day(baseline.recordedAt)})`)}${count ? ` — ${plural(count, "newer release")}:` : ""}`; - case "flag": - return baseline.version ? `Toolkit changes since ${v}:` : `Toolkit changes since ${paint(STYLE.bold, day(baseline.date))}:`; + return `T${project.slice(1)} is on toolkit ${v} ${paint(STYLE.dim, `(recorded by 10x sync on ${day(baseline.recordedAt)})`)}${count ? ` — ${plural(count, "newer release")}:` : ""}`; + case "flag": { + const since = baseline.version ? v : paint(STYLE.bold, day(baseline.date)); + return `Toolkit changes${course ? ` for ${course}` : ""} since ${since}:`; + } case "applied": - return `No toolkit version recorded in this project — showing changes since your last lesson apply (${day(baseline.date)}):`; + return `No toolkit version recorded in ${project} — showing changes since your last lesson apply (${day(baseline.date)}):`; case "none": - return `No toolkit version recorded in this project — showing the latest ${plural(count, "release")}:`; + return `No toolkit version recorded${course ? ` for ${course}` : " in this project"} — showing the latest ${plural(count, "release")}:`; } } @@ -283,22 +323,22 @@ function pushSyncHint(lines: string[], baseline: ChangelogBaseline): void { ); } -function render(ctx: OutputContext, baseline: ChangelogBaseline, entries: ChangelogEntry[]): void { +function render(ctx: OutputContext, baseline: ChangelogBaseline, scope: CourseScope, entries: ChangelogEntry[]): void { if (ctx.json) { - output(ctx, "", { baseline, newEntries: entries.length, entries }); + output(ctx, "", { baseline, course: scope.course ?? null, newEntries: entries.length, entries }); return; } if (entries.length === 0) { // Only a synced project has a version worth stating when nothing is new. - const lines = baseline.source === "sync" ? [header(baseline, 0), emptyMessage(baseline)] : [emptyMessage(baseline)]; + const lines = baseline.source === "sync" ? [header(baseline, scope, 0), emptyMessage(baseline)] : [emptyMessage(baseline)]; pushSyncHint(lines, baseline); output(ctx, lines.join("\n"), undefined); return; } const width = Math.min(process.stderr.columns || 80, MAX_WIDTH); - const lines = [header(baseline, entries.length)]; + const lines = [header(baseline, scope, entries.length)]; for (const entry of entries) lines.push("", ...renderEntry(entry, width)); pushSyncHint(lines, baseline); output(ctx, lines.join("\n"), undefined); diff --git a/src/commands/get.ts b/src/commands/get.ts index 17bff1c..734a044 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -215,7 +215,7 @@ export async function runGet( // Baseline for `10x changelog`: only after a complete (unfiltered) real apply. if (!dryRun && !isFiltered) { - await recordLatestToolkitVersion(ctx, auth.access_token, join(process.cwd(), profile.manifestDir)); + await recordLatestToolkitVersion(ctx, auth.access_token, join(process.cwd(), profile.manifestDir), course); } renderGetResult(ctx, bundle, writeResult, dryRun, profile, { diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 9d744ed..ea3342b 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -227,7 +227,7 @@ export async function runSync(ctx: OutputContext, options: SyncFlags): Promise o.status === "errored")) { - await recordLatestToolkitVersion(ctx, auth.access_token, join(process.cwd(), profile.manifestDir)); + await recordLatestToolkitVersion(ctx, auth.access_token, join(process.cwd(), profile.manifestDir), course); } renderReport(ctx, profile, { diff --git a/src/generated/api-types.ts b/src/generated/api-types.ts index ae914cc..a9c5ab9 100644 --- a/src/generated/api-types.ts +++ b/src/generated/api-types.ts @@ -413,13 +413,14 @@ export interface paths { }; /** * Release changelog entries - * @description Toolkit release changelog, newest first by semver, limited to the releases the caller's course grants receive (a frozen course stops at its cutoff release). `since` is an exclusive version bound; `sinceDate` is an inclusive `releasedAt >=` bound. They are mutually exclusive. + * @description Toolkit release changelog, newest first by semver, limited to the releases the requested `course` received, or without it those of every course the caller is granted (a frozen course stops at its cutoff release). `since` is an exclusive version bound; `sinceDate` is an inclusive `releasedAt >=` bound. They are mutually exclusive. */ get: { parameters: { query?: { since?: string; sinceDate?: string; + course?: string; limit?: number; }; header?: never; @@ -486,7 +487,7 @@ export interface paths { }; }; }; - /** @description The token grants no registered course (`course_access_denied`) */ + /** @description The token grants no registered course, or not the requested `course` (`course_access_denied`) */ 403: { headers: { [name: string]: unknown; @@ -497,6 +498,17 @@ export interface paths { }; }; }; + /** @description `course` names no registered course (`course_not_found`) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; /** @description Changelog index unreadable, corrupt or schema-invalid */ 500: { headers: { diff --git a/src/lib/api-content.ts b/src/lib/api-content.ts index 0ca490a..efb7c53 100644 --- a/src/lib/api-content.ts +++ b/src/lib/api-content.ts @@ -53,6 +53,8 @@ export interface ChangelogQuery { since?: string; /** Inclusive lower bound by date (`YYYY-MM-DD` or ISO datetime). */ sinceDate?: string; + /** Only releases this course received (the project's course); must be granted. */ + course?: string; /** 1–100; the backend defaults to 20. */ limit?: number; } @@ -92,11 +94,13 @@ export async function fetchChangelog(token: string, query: ChangelogQuery = {}): const params = new URLSearchParams(); if (query.since !== undefined) params.set("since", query.since); if (query.sinceDate !== undefined) params.set("sinceDate", query.sinceDate); + if (query.course !== undefined) params.set("course", query.course); if (query.limit !== undefined) params.set("limit", String(query.limit)); const qs = params.toString(); const result = await apiGet(`/api/changelog${qs ? `?${qs}` : ""}`, { token }); if (!result.ok) { - if (result.status === 404) return { ...result, code: "changelog_unsupported", error: "The backend does not support the toolkit changelog yet." }; + // A 404 naming the course is an answer from a current backend, not a missing route. + if (result.status === 404 && result.code !== "course_not_found") return { ...result, code: "changelog_unsupported", error: "The backend does not support the toolkit changelog yet." }; return result; } if (!validateChangelogResponse(result.data)) return { ok: false, status: 0, code: "changelog_invalid", error: "Invalid toolkit changelog response." }; diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index a620c7b..715a251 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -136,6 +136,18 @@ export function recordToolkitVersion(dir: string, version: string, now: Date): b return true; } +/** + * Drop a recorded toolkit version (re-reading first, as above). Returns true + * only when one was removed. + */ +export function clearToolkitVersion(dir: string): boolean { + const manifest = readManifest(dir); + if (!manifest?.toolkit) return false; + delete manifest.toolkit; + writeManifest(dir, manifest); + return true; +} + /** * Write the manifest to `/.10x-cli-manifest.json`, creating `` * first if it doesn't already exist. diff --git a/src/lib/toolkit-baseline.ts b/src/lib/toolkit-baseline.ts index c100e24..4ce6fc6 100644 --- a/src/lib/toolkit-baseline.ts +++ b/src/lib/toolkit-baseline.ts @@ -1,6 +1,7 @@ /** - * Records the newest 10x-toolkit release version in the project manifest after - * a successful pull, so `10x changelog` has a per-project baseline. + * Records the newest 10x-toolkit release the project's course received in the + * project manifest after a successful pull, so `10x changelog` has a + * per-project baseline. * * Best-effort by contract: any failure (older backend without the route, * network, empty list, invalid response, unreadable manifest) leaves the @@ -9,26 +10,31 @@ */ import { fetchChangelog } from "./api-content"; -import { readManifest, recordToolkitVersion } from "./manifest"; +import { clearToolkitVersion, readManifest, recordToolkitVersion } from "./manifest"; import { type OutputContext, verbose } from "./output"; export async function recordLatestToolkitVersion( ctx: OutputContext, token: string, manifestDir: string, + course: string, now: () => Date = () => new Date(), ): Promise { try { // No manifest → nothing to record into; skip the request entirely. if (!readManifest(manifestDir)) return; - const result = await fetchChangelog(token, { limit: 1 }); + // The newest release this project's course received, not the account's. + const result = await fetchChangelog(token, { limit: 1, course }); if (!result.ok) { verbose(ctx, `toolkit version not recorded: ${result.code}`); return; } const latest = result.data.entries[0]; if (!latest) { - verbose(ctx, "toolkit version not recorded: no changelog entries"); + // The course has no release to record. A version stored before records + // were course-scoped (an account-wide one) would misstate the project. + if (clearToolkitVersion(manifestDir)) verbose(ctx, `cleared toolkit version: no ${course} releases`); + else verbose(ctx, "toolkit version not recorded: no changelog entries"); return; } if (recordToolkitVersion(manifestDir, latest.version, now())) verbose(ctx, `recorded toolkit ${latest.version}`); diff --git a/tests/api-changelog.test.ts b/tests/api-changelog.test.ts index 82866e1..c3b8d87 100644 --- a/tests/api-changelog.test.ts +++ b/tests/api-changelog.test.ts @@ -79,6 +79,12 @@ describe("fetchChangelog — request", () => { expect(requests[1]!.url.searchParams.get("limit")).toBe("5"); expect(requests[2]!.url.search).toBe(""); }); + + it("passes the course scope through", async () => { + stubFetch(() => Response.json({ entries: [] })); + await fetchChangelog("t", { course: "10xdevs3", limit: 1 }); + expect(requests[0]!.url.searchParams.get("course")).toBe("10xdevs3"); + }); }); describe("fetchChangelog — responses", () => { @@ -101,6 +107,12 @@ describe("fetchChangelog — responses", () => { expect(result).toMatchObject({ ok: false, status: 404, code: "changelog_unsupported" }); }); + it("keeps a course_not_found 404 as an answer, not an unsupported backend", async () => { + stubFetch(() => Response.json({ error: "course_not_found" }, { status: 404 })); + const result = await fetchChangelog("t", { course: "10xdevs9" }); + expect(result).toMatchObject({ ok: false, status: 404, code: "course_not_found" }); + }); + it("passes other API errors through unchanged", async () => { stubFetch(() => Response.json({ error: "invalid_query" }, { status: 400 })); expect(await fetchChangelog("t", { since: "v1.0.0", sinceDate: "2026-10-01" })).toMatchObject({ ok: false, status: 400, code: "invalid_query" }); diff --git a/tests/changelog-command.test.ts b/tests/changelog-command.test.ts index 00cdbfb..1e3942e 100644 --- a/tests/changelog-command.test.ts +++ b/tests/changelog-command.test.ts @@ -7,7 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import cac from "cac"; @@ -183,6 +183,7 @@ interface OkEnvelope { status: "ok"; data: { baseline: { source: string; version?: string; date?: string; recordedAt?: string }; + course: string | null; newEntries: number; entries: ChangelogEntry[]; }; @@ -231,7 +232,7 @@ describe("10x changelog — baseline resolution", () => { const { stdout, exitCode } = await runChangelog(["changelog", "--json"]); expect(exitCode ?? 0).toBe(0); - expect(queries).toEqual([{ since: "v2.59.2", limit: 20 }]); + expect(queries).toEqual([{ since: "v2.59.2", course: "10xdevs3", limit: 20 }]); const data = parseOk(stdout); expect(data.baseline).toEqual({ source: "sync", version: "v2.59.2", recordedAt: "2026-10-05T10:00:00.000Z" }); expect(data.newEntries).toBe(1); @@ -245,7 +246,7 @@ describe("10x changelog — baseline resolution", () => { const { stdout, exitCode } = await runChangelog(["changelog", "--json"]); expect(exitCode ?? 0).toBe(0); - expect(queries).toEqual([{ sinceDate: "2026-09-15T12:30:00.000Z", limit: 20 }]); + expect(queries).toEqual([{ sinceDate: "2026-09-15T12:30:00.000Z", course: "10xdevs3", limit: 20 }]); const data = parseOk(stdout); expect(data.baseline).toEqual({ source: "applied", date: "2026-09-15T12:30:00.000Z" }); expect(data.newEntries).toBe(0); @@ -259,7 +260,7 @@ describe("10x changelog — baseline resolution", () => { respondWith([]); const { stdout } = await runChangelog(["changelog", "--json"]); - expect(queries).toEqual([{ sinceDate: "2026-09-20T08:00:00.000Z", limit: 20 }]); + expect(queries).toEqual([{ sinceDate: "2026-09-20T08:00:00.000Z", course: "10xdevs3", limit: 20 }]); expect(parseOk(stdout).baseline).toEqual({ source: "applied", date: "2026-09-20T08:00:00.000Z" }); }); @@ -271,7 +272,7 @@ describe("10x changelog — baseline resolution", () => { respondWith([]); const { stdout } = await runChangelog(["changelog", "--json"]); - expect(queries).toEqual([{ since: "v2.20.0", limit: 20 }]); + expect(queries).toEqual([{ since: "v2.20.0", course: "10xdevs3", limit: 20 }]); expect(parseOk(stdout).baseline).toEqual({ source: "sync", version: "v2.20.0", recordedAt: "2026-10-02T00:00:00.000Z" }); }); @@ -314,7 +315,7 @@ describe("10x changelog — --since flag", () => { const { stdout, exitCode } = await runChangelog(["changelog", "--json", "--since", "v2.55.0"]); expect(exitCode ?? 0).toBe(0); - expect(queries).toEqual([{ since: "v2.55.0", limit: 20 }]); + expect(queries).toEqual([{ since: "v2.55.0", course: "10xdevs3", limit: 20 }]); expect(parseOk(stdout).baseline).toEqual({ source: "flag", version: "v2.55.0" }); }); @@ -413,7 +414,7 @@ describe("10x changelog — human output", () => { const { stdout, stderr, exitCode } = await withHumanTTY(() => runChangelog(["changelog"])); expect(exitCode ?? 0).toBe(0); expect(stdout).toBe(""); - expect(stderr).toContain("This project is on toolkit v2.59.2 (recorded by 10x sync on 2026-10-05)"); + expect(stderr).toContain("This 10xdevs3 project is on toolkit v2.59.2 (recorded by 10x sync on 2026-10-05)"); expect(stderr).toContain("You're up to date — no toolkit changes since then."); expect(stderr).not.toContain("Run 10x sync"); }); @@ -439,7 +440,7 @@ describe("10x changelog — human output", () => { const { stderr, exitCode } = await withHumanTTY(() => runChangelog(["changelog"])); expect(exitCode ?? 0).toBe(0); - expect(stderr).toContain("This project is on toolkit v2.59.2 (recorded by 10x sync on 2026-10-05) — 2 newer releases:"); + expect(stderr).toContain("This 10xdevs3 project is on toolkit v2.59.2 (recorded by 10x sync on 2026-10-05) — 2 newer releases:"); expect(stderr).toContain("v2.61.0 — 2026-10-07\n • Plans now have sharper phases."); expect(stderr).toContain(" Skills\n ~ 10x-plan\n Sharper phases.\n + 10x-ray (new)"); expect(stderr).toContain(" Rules\n - old-rule (removed)"); @@ -481,3 +482,68 @@ describe("renderEntry", () => { } }); }); + +describe("10x changelog — course scope", () => { + it("scopes to the project's course from a binding file even without a manifest", async () => { + writeValidAuth(); + writeFileSync(join(project, ".10x-cli.json"), `${JSON.stringify({ version: 1, course: "10xdevs4" })}\n`); + respondWith([]); + const { stdout } = await runChangelog(["changelog", "--json"]); + expect(queries).toEqual([{ course: "10xdevs4", limit: 5 }]); + expect(parseOk(stdout).course).toBe("10xdevs4"); + }); + + it("--course overrides the project's course", async () => { + writeValidAuth(); + writeProjectManifest(makeManifest()); + respondWith([]); + await runChangelog(["changelog", "--json", "--course", "10xdevs4"]); + expect(queries[0]).toMatchObject({ course: "10xdevs4" }); + }); + + it("--course needs a value", async () => { + writeValidAuth(); + // One CLI run per test: the capture helper restores process.exit a tick after resolving. + const { stdout, exitCode } = await runChangelog(["changelog", "--json", "--course", " "]); + expect(exitCode).toBe(2); + parseErr(stdout, "invalid_course"); + }); + + it("outside a project it sends no course and reports null", async () => { + writeValidAuth(); + respondWith([]); + const { stdout } = await runChangelog(["changelog", "--json"]); + expect(queries).toEqual([{ limit: 5 }]); + expect(parseOk(stdout).course).toBeNull(); + }); + + it("a project whose manifests disagree on the course stops, as get/sync do", async () => { + writeValidAuth(); + writeProjectManifest(makeManifest()); + writeProjectManifest(makeManifest({ tool: "cursor", course: "10xdevs4" }), ".cursor"); + respondWith([]); + const { stdout, exitCode } = await runChangelog(["changelog", "--json"]); + expect(exitCode).toBe(1); + parseErr(stdout, "course_binding_conflict"); + expect(queries).toEqual([]); + }); + + it("names the project's course when the account lacks it", async () => { + writeValidAuth(); + writeProjectManifest(makeManifest()); + apiContentMockState.fetchChangelogImpl = () => ({ ok: false, status: 403, code: "course_access_denied", error: "Forbidden" }); + const { stdout, exitCode } = await runChangelog(["changelog", "--json"]); + expect(exitCode).toBe(4); + const error = parseErr(stdout, "course_access_denied"); + expect(error.message).toContain("10xdevs3"); + expect(error.hint).toContain("This project uses 10xdevs3"); + }); + + it("an unknown --course is a usage error", async () => { + writeValidAuth(); + apiContentMockState.fetchChangelogImpl = () => ({ ok: false, status: 404, code: "course_not_found", error: "Not found" }); + const { stdout, exitCode } = await runChangelog(["changelog", "--json", "--course", "10xdevs9"]); + expect(exitCode).toBe(2); + parseErr(stdout, "course_not_found"); + }); +}); diff --git a/tests/get-command.test.ts b/tests/get-command.test.ts index 48c0740..873f867 100644 --- a/tests/get-command.test.ts +++ b/tests/get-command.test.ts @@ -872,7 +872,7 @@ describe("10x get — toolkit baseline recording", () => { expect(exitCode ?? 0).toBe(0); expect(Object.keys(parseOk>(stdout))).toEqual(GET_ENVELOPE_KEYS); - expect(changelogCalls).toEqual([{ limit: 1 }]); + expect(changelogCalls).toEqual([{ limit: 1, course: "10xdevs3" }]); const manifest = readManifest(join(projectRoot, ".claude"))!; expect(manifest.toolkit?.version).toBe("v2.59.2"); expect(Object.keys(manifest.lessons!)).toEqual(["m1l1"]); @@ -887,7 +887,7 @@ describe("10x get — toolkit baseline recording", () => { expect(exitCode ?? 0).toBe(0); expect(Object.keys(parseOk>(stdout))).toEqual(GET_ENVELOPE_KEYS); - expect(changelogCalls).toEqual([{ limit: 1 }]); + expect(changelogCalls).toEqual([{ limit: 1, course: "10xdevs3" }]); expect(readManifest(join(projectRoot, ".claude"))!.toolkit).toBeUndefined(); }); diff --git a/tests/sync-command.test.ts b/tests/sync-command.test.ts index 55be67c..de58e77 100644 --- a/tests/sync-command.test.ts +++ b/tests/sync-command.test.ts @@ -753,7 +753,7 @@ describe("10x sync — toolkit baseline recording", () => { expect(res.exitCode).toBeUndefined(); expect(fetched).toEqual([]); expect((envelope(res.stdout).data.lessons as Array<{ status: string }>)[0]!.status).toBe("unchanged"); - expect(changelogCalls).toEqual([{ limit: 1 }]); + expect(changelogCalls).toEqual([{ limit: 1, course: "10xdevs3" }]); const toolkit = readManifestFile()["toolkit"] as { version: string; recordedAt: string }; expect(toolkit.version).toBe("v2.59.2"); expect(Number.isNaN(Date.parse(toolkit.recordedAt))).toBe(false); @@ -830,7 +830,7 @@ describe("10x sync — toolkit baseline recording", () => { const res = await runSyncCmd(["--tool", "claude-code"]); expect(res.exitCode).toBeUndefined(); - expect(changelogCalls).toEqual([{ limit: 1 }]); + expect(changelogCalls).toEqual([{ limit: 1, course: "10xdevs3" }]); expect(envelope(res.stdout).status).toBe("ok"); expect(readFileSync(manifestPath(), "utf8")).toBe(before); }); @@ -852,6 +852,24 @@ describe("10x sync — toolkit baseline recording", () => { } }); + it("a failed lookup keeps a recorded version", async () => { + await seedM1l1(); + const stale = { ...JSON.parse(readFileSync(manifestPath(), "utf8")), toolkit: { version: "v2.59.3", recordedAt: "2026-10-06T10:19:39.687Z" } }; + writeFileSync(manifestPath(), JSON.stringify(stale)); + changelogReturns({ ok: false, status: 0, code: "network_error", error: "offline" } as ApiResult); + await runSyncCmd(["--tool", "claude-code"]); + expect(JSON.parse(readFileSync(manifestPath(), "utf8")).toolkit?.version).toBe("v2.59.3"); + }); + + it("an empty course changelog removes the stale version", async () => { + await seedM1l1(); + const stale = { ...JSON.parse(readFileSync(manifestPath(), "utf8")), toolkit: { version: "v2.59.3", recordedAt: "2026-10-06T10:19:39.687Z" } }; + writeFileSync(manifestPath(), JSON.stringify(stale)); + changelogReturns({ ok: true, status: 200, responseHeaders: new Headers(), rawBody: "", data: { entries: [] } } as ApiResult); + await runSyncCmd(["--tool", "claude-code"]); + expect(JSON.parse(readFileSync(manifestPath(), "utf8")).toolkit).toBeUndefined(); + }); + it("is a no-op without a manifest (nothing synced)", async () => { changelogReturns(latest("v2.59.2")); wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), { m1l1: makeBundle("m1l1", "v1") });