Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -298,6 +305,7 @@ releases and suggests running `10x sync`.
| Flag | Description |
|------|-------------|
| `--since <ref>` | Ignore the baseline: `vX.Y.Z` lists releases **after** that version; `YYYY-MM-DD` lists releases **from** the start of that UTC day |
| `--course <course>` | Show one course's releases instead of this project's course (e.g. `10xdevs4`) |
| `--limit <n>` | Maximum number of releases, 1-100 (default 20; 5 without a baseline) |

```bash
Expand All @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions skills/10x-cli-guide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
68 changes: 54 additions & 14 deletions src/commands/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand All @@ -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,
Expand All @@ -34,6 +39,7 @@ import { DEFAULT_TOOL, getToolProfile, PROFILES, type ToolProfile } from "../lib

interface ChangelogFlags extends GlobalFlags {
since?: unknown;
course?: unknown;
limit?: unknown;
}

Expand All @@ -58,6 +64,7 @@ export function registerChangelogCommand(cli: CAC): void {
cli
.command("changelog", "Show 10x-toolkit changes since your last sync")
.option("--since <ref>", "Show changes after a version (vX.Y.Z) or from a date (YYYY-MM-DD)")
.option("--course <course>", "Show one course's releases (default: this project's course)")
.option("--limit <n>", `Maximum number of entries, 1-${MAX_LIMIT} (default: ${DEFAULT_CHANGELOG_LIMIT})`)
.action(async (options: ChangelogFlags) => {
const ctx = resolveContext(options);
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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.");
}
Expand All @@ -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")}:`;
}
}

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export async function runSync(ctx: OutputContext, options: SyncFlags): Promise<v
// Baseline for `10x changelog`: only after a finished, real pull with no
// errored lesson (a skipped conflict still counts — that content was offered).
if (!dryRun && !controller.signal.aborted && !outcomes.some((o) => 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, {
Expand Down
16 changes: 14 additions & 2 deletions src/generated/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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: {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/api-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<ChangelogResponse>(`/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." };
Expand Down
12 changes: 12 additions & 0 deletions src/lib/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/.10x-cli-manifest.json`, creating `<dir>`
* first if it doesn't already exist.
Expand Down
16 changes: 11 additions & 5 deletions src/lib/toolkit-baseline.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<void> {
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}`);
Expand Down
Loading
Loading