Files
przeprogramowani b0c789af70 feat: enable v4 course selection and protected first-week sync (#38)
* docs(10xdevs4-cli-access): record membership gates and bootstrap context (p1)

Add canonical access plan, accepted decisions and supporting context.
Record phase 1 verification, Toolkit revision and remaining evidence gaps.

* docs(10xdevs4-cli-access): record course access gates and scoped review (p2)

Update canonical Progress, change status, evidence and implementation review.
Record Toolkit revisions, inherited typecheck limitation and phase 3 prerequisites.

* docs(10xdevs4-cli-access): persist reviewed revisions and phase 3 boundary

Record final reviewed Toolkit and CLI context revisions.
Persist Progress attribution and unresolved W04/W05/W08 prerequisites.
Keep phases 3–6, phase 7 and Manual criteria open.

* docs(10xdevs4-cli-access): record squash-safe source prerequisite

Record PR #30, verified gates, permanent-pin lessons and the remaining merge dependency.
Keep phase 3 and all manual rollout criteria pending.

* docs(10xdevs4-cli-access): record source prerequisite review

Record independent review of PR #30 and verified CI evidence.
Keep the master pin and delivery phase completion pending.

* docs(10xdevs4-cli-access): distinguish candidate checks from final master pins

Record passing pre-merge v4 checks and defer workflow suspension.
Keep the final v3 maintenance pin dependent on the resulting master SHA.

* feat: prepare v4 course delivery and protected project sync

Capture the reviewed implementation and manual rehearsal for draft PR review. Master source prerequisites, full clean verification and coordinated Windows CI remain open; production rollout is separate.

* fix: keep paid CI evidence private and converge cumulative sync

* docs: record merged source prerequisite and passing clean gate

* fix: preserve generated API type line endings on Windows

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-13 09:24:16 +02:00

581 lines
20 KiB
TypeScript

/**
* 10x get --print — print mode behavior.
*
* Tests the --print, --type, and --name flags. Print mode outputs artifact
* content to stdout instead of writing to the filesystem.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } 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 { ArtifactResponse, LessonBundle } from "../src/lib/api-content";
import { AUTH_FILE_VERSION, type AuthData, saveAuth } from "../src/lib/config";
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;
fn()
.then(() => resolve({ stdout, stderr }))
.catch((err: unknown) => {
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)}]`,
});
}
})
.finally(() => {
process.stdout.write = realStdoutWrite;
process.stderr.write = realStderrWrite;
process.exit = realExit;
});
});
}
async function runGet(argv: string[]): Promise<CaptureResult> {
return captureStreams(async () => {
const { registerGetCommand } = await import("../src/commands/get");
const cli = cac("10x");
cli.option("--json", "Output as JSON (auto-detected when piped)");
cli.option("--verbose", "Show detailed output on stderr");
registerGetCommand(cli);
cli.parse(["bun", "10x", ...argv], { run: false });
await cli.runMatchedCommand();
});
}
let tmp: string;
let projectRoot: string;
let priorIsTTY: boolean | undefined;
let priorCwd: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "10x-cli-print-"));
projectRoot = join(tmp, "project");
mkdirSync(projectRoot, { recursive: true });
redirectConfigDir(tmp);
priorIsTTY = process.stdout.isTTY;
process.stdout.isTTY = false; // force JSON mode
priorCwd = process.cwd();
process.chdir(projectRoot);
resetApiContentMock();
});
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 });
});
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 makeBundle(overrides: Partial<LessonBundle> = {}): LessonBundle {
return {
lessonId: "m1l1",
module: 1,
lesson: 1,
title: "Intro to Claude Code",
summary: "First steps with AI pair programming",
skills: [
{
name: "code-review",
files: [
{ path: "SKILL.md", content: "# Code Review Skill\nReview code carefully." },
],
},
{
name: "debugging",
files: [
{ path: "SKILL.md", content: "# Debugging Skill\nDebug step by step." },
],
},
],
prompts: [{ name: "plan", content: "prompt md" }],
rules: [{ name: "tdd", content: "rules md" }],
configs: [{ name: "settings.json", content: "{}" }],
...overrides,
};
}
function lessonOk(bundle: LessonBundle): ApiResult<LessonBundle> {
return { ok: true, status: 200, data: bundle, responseHeaders: new Headers(), rawBody: "" };
}
function artifactOk(artifact: ArtifactResponse): ApiResult<ArtifactResponse> {
return { ok: true, status: 200, data: artifact, responseHeaders: new Headers(), rawBody: "" };
}
function artifactErr(
status: number,
code: string,
error: string,
payload?: Record<string, unknown>,
): ApiResult<ArtifactResponse> {
return { ok: false, status, code, error, payload };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("10x get --print — validation", () => {
it("--print without --type errors with exit code 2 (USAGE)", async () => {
writeValidAuth();
const { stdout, exitCode } = await runGet(["get", "m1l1", "--print", "--json"]);
expect(exitCode).toBe(2);
const parsed = JSON.parse(stdout.trim());
expect(parsed.status).toBe("error");
expect(parsed.error.code).toBe("missing_type");
});
it("--print with invalid --type errors with exit code 2 (USAGE)", async () => {
writeValidAuth();
const { stdout, exitCode } = await runGet(["get", "m1l1", "--print", "--type", "invalid", "--json"]);
expect(exitCode).toBe(2);
const parsed = JSON.parse(stdout.trim());
expect(parsed.status).toBe("error");
expect(parsed.error.code).toBe("invalid_type");
});
});
describe("10x get --print --type --name — single artifact", () => {
it("outputs raw markdown to stdout (JSON mode)", async () => {
writeValidAuth();
const artifact: ArtifactResponse = {
type: "skills",
name: "code-review",
files: [
{ path: "SKILL.md", content: "# Code Review Skill\nReview code carefully." },
],
};
apiContentMockState.fetchArtifactImpl = (_course, _id, _type, _name, _tool, _token) =>
artifactOk(artifact);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "skills", "--name", "code-review", "--json",
]);
expect(exitCode ?? 0).toBe(0);
const parsed = JSON.parse(stdout.trim());
expect(parsed.status).toBe("ok");
expect(parsed.data.type).toBe("skills");
expect(parsed.data.name).toBe("code-review");
expect(parsed.data.files[0].path).toBe("SKILL.md");
expect(parsed.data.files[0].content).toBe("# Code Review Skill\nReview code carefully.");
});
it("outputs raw content to stdout (non-JSON / human mode)", async () => {
writeValidAuth();
// Force human mode by setting isTTY to true; pass --tool to avoid interactive prompt
process.stdout.isTTY = true;
const artifact: ArtifactResponse = {
type: "skills",
name: "code-review",
files: [
{ path: "SKILL.md", content: "# Code Review Skill\nReview code carefully." },
],
};
apiContentMockState.fetchArtifactImpl = () => artifactOk(artifact);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "skills", "--name", "code-review",
"--tool", "claude-code",
]);
expect(exitCode ?? 0).toBe(0);
expect(stdout).toBe("# Code Review Skill\nReview code carefully.");
});
it("emits a stderr notice listing extra files for a multi-file skill (human mode)", async () => {
writeValidAuth();
process.stdout.isTTY = true;
const artifact: ArtifactResponse = {
type: "skills",
name: "10x-plan",
files: [
{ path: "SKILL.md", content: "# 10x-plan\n" },
{
path: "scripts/check-context.sh",
content: "#!/bin/bash\n",
executable: true,
},
{ path: "references/format.md", content: "# format\n" },
],
};
apiContentMockState.fetchArtifactImpl = () => artifactOk(artifact);
const { stdout, stderr, exitCode } = await runGet([
"get",
"m1l1",
"--print",
"--type",
"skills",
"--name",
"10x-plan",
"--tool",
"claude-code",
]);
expect(exitCode ?? 0).toBe(0);
// stdout contains ONLY SKILL.md content — extras don't leak.
expect(stdout).toBe("# 10x-plan\n");
// stderr names every extra path in document order.
expect(stderr).toContain('Note: skill "10x-plan" has 2 additional files');
expect(stderr).toContain("scripts/check-context.sh");
expect(stderr).toContain("references/format.md");
expect(stderr).toContain("Run without --print to materialize all files");
});
it("does not emit a notice for a single-file skill", async () => {
writeValidAuth();
process.stdout.isTTY = true;
const artifact: ArtifactResponse = {
type: "skills",
name: "code-review",
files: [{ path: "SKILL.md", content: "# only one file\n" }],
};
apiContentMockState.fetchArtifactImpl = () => artifactOk(artifact);
const { stdout, stderr, exitCode } = await runGet([
"get",
"m1l1",
"--print",
"--type",
"skills",
"--name",
"code-review",
"--tool",
"claude-code",
]);
expect(exitCode ?? 0).toBe(0);
expect(stdout).toBe("# only one file\n");
expect(stderr).not.toContain("additional file");
});
it("does not emit the stderr notice in JSON mode (full files[] is already in stdout)", async () => {
writeValidAuth();
const artifact: ArtifactResponse = {
type: "skills",
name: "10x-plan",
files: [
{ path: "SKILL.md", content: "# 10x-plan\n" },
{ path: "scripts/check-context.sh", content: "#!/bin/bash\n", executable: true },
],
};
apiContentMockState.fetchArtifactImpl = () => artifactOk(artifact);
const { stderr, exitCode } = await runGet([
"get",
"m1l1",
"--print",
"--type",
"skills",
"--name",
"10x-plan",
"--json",
]);
expect(exitCode ?? 0).toBe(0);
expect(stderr).not.toContain("additional file");
});
it("passes tool param to fetchArtifact", async () => {
writeValidAuth();
let capturedTool = "";
apiContentMockState.fetchArtifactImpl = (_course, _id, _type, _name, tool, _token) => {
capturedTool = tool;
return artifactOk({
type: "skills",
name: "code-review",
files: [{ path: "SKILL.md", content: "skill content" }],
});
};
await runGet([
"get", "m1l1", "--print", "--type", "skills", "--name", "code-review",
"--tool", "cursor", "--json",
]);
expect(capturedTool).toBe("cursor");
});
it("handles 404 for nonexistent artifact", async () => {
writeValidAuth();
apiContentMockState.fetchArtifactImpl = () =>
artifactErr(404, "not_found", "Artifact not found");
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "skills", "--name", "nonexistent", "--json",
]);
expect(exitCode).toBe(5);
const parsed = JSON.parse(stdout.trim());
expect(parsed.status).toBe("error");
expect(parsed.error.code).toBe("lesson_not_found");
});
it("handles 403 for locked module", async () => {
writeValidAuth();
apiContentMockState.fetchArtifactImpl = () =>
artifactErr(403, "module_locked", "module_locked", { module: 2, releaseAt: "2026-05-11T07:00:00Z" });
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "skills", "--name", "code-review", "--json",
]);
expect(exitCode).toBe(4);
const parsed = JSON.parse(stdout.trim());
expect(parsed.error.code).toBe("module_locked");
});
});
describe("10x get --print --type — type filter (no name)", () => {
it("outputs all skills concatenated with --- separators (JSON mode)", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "skills", "--json",
]);
expect(exitCode ?? 0).toBe(0);
const parsed = JSON.parse(stdout.trim());
expect(parsed.status).toBe("ok");
// In JSON mode, returns the array of artifacts
expect(parsed.data.artifacts).toBeArray();
expect(parsed.data.artifacts.length).toBe(2);
expect(parsed.data.artifacts[0].name).toBe("code-review");
expect(parsed.data.artifacts[1].name).toBe("debugging");
});
it("outputs concatenated content with --- separators (human mode)", async () => {
writeValidAuth();
process.stdout.isTTY = true;
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "skills", "--tool", "claude-code",
]);
expect(exitCode ?? 0).toBe(0);
expect(stdout).toBe(
"# Code Review Skill\nReview code carefully.\n---\n# Debugging Skill\nDebug step by step.",
);
});
it("emits one stderr notice per multi-file skill in the bundle (human mode)", async () => {
writeValidAuth();
process.stdout.isTTY = true;
const bundle = makeBundle({
skills: [
{
name: "10x-plan",
files: [
{ path: "SKILL.md", content: "# 10x-plan\n" },
{
path: "scripts/check-context.sh",
content: "#!/bin/bash\n",
executable: true,
},
],
},
{
// single-file skill — must NOT trigger a notice
name: "code-review",
files: [{ path: "SKILL.md", content: "# code-review\n" }],
},
],
});
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stderr, exitCode } = await runGet([
"get",
"m1l1",
"--print",
"--type",
"skills",
"--tool",
"claude-code",
]);
expect(exitCode ?? 0).toBe(0);
expect(stderr).toContain('Note: skill "10x-plan" has 1 additional file ');
expect(stderr).toContain("scripts/check-context.sh");
// Single-file skill should not generate a notice.
expect(stderr).not.toContain('"code-review"');
});
it("--print with --type prompts outputs prompt content", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--print", "--type", "prompts", "--json",
]);
expect(exitCode ?? 0).toBe(0);
const parsed = JSON.parse(stdout.trim());
expect(parsed.data.artifacts).toBeArray();
expect(parsed.data.artifacts.length).toBe(1);
expect(parsed.data.artifacts[0].name).toBe("plan");
});
it("--print does not write files or update manifest", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
await runGet(["get", "m1l1", "--print", "--type", "skills", "--json"]);
// No .claude directory should exist
const { existsSync } = await import("node:fs");
expect(existsSync(join(projectRoot, ".claude"))).toBe(false);
});
});
describe("10x get --type --name — filtered writes (no --print)", () => {
it("--type skills writes only skills, not prompts/rules/configs", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--type", "skills", "--json",
]);
expect(exitCode ?? 0).toBe(0);
const parsed = JSON.parse(stdout.trim());
expect(parsed.data.counts.skills).toBe(2);
expect(parsed.data.counts.prompts).toBe(0);
expect(parsed.data.counts.rules).toBe(0);
expect(parsed.data.counts.configs).toBe(0);
});
it("--type skills --name code-review writes only that one skill", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--type", "skills", "--name", "code-review", "--json",
]);
expect(exitCode ?? 0).toBe(0);
const parsed = JSON.parse(stdout.trim());
expect(parsed.data.counts.skills).toBe(1);
expect(parsed.data.writes.skills[0].name).toBe("code-review");
expect(parsed.data.counts.prompts).toBe(0);
});
it("--type skills --name nonexistent errors with artifact_not_found", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--type", "skills", "--name", "nonexistent", "--json",
]);
expect(exitCode).toBe(5);
const parsed = JSON.parse(stdout.trim());
expect(parsed.error.code).toBe("artifact_not_found");
});
it("--name without --type errors with missing_type (no auth needed)", async () => {
// Validation fires before auth — no writeValidAuth() needed
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--name", "code-review", "--json",
]);
expect(exitCode).toBe(2);
const parsed = JSON.parse(stdout.trim());
expect(parsed.error.code).toBe("missing_type");
});
it("--type with invalid value errors with invalid_type (no auth needed)", async () => {
const { stdout, exitCode } = await runGet([
"get", "m1l1", "--type", "widgets", "--json",
]);
expect(exitCode).toBe(2);
const parsed = JSON.parse(stdout.trim());
expect(parsed.error.code).toBe("invalid_type");
});
it("--type skills actually writes only the skill file to disk", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
await runGet(["get", "m1l1", "--type", "skills", "--json"]);
const { existsSync } = await import("node:fs");
// Skills should exist
expect(existsSync(join(projectRoot, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(projectRoot, ".claude/skills/debugging/SKILL.md"))).toBe(true);
// Prompts should NOT exist
expect(existsSync(join(projectRoot, ".claude/prompts/plan.md"))).toBe(false);
// Config-templates should NOT exist
expect(existsSync(join(projectRoot, ".claude/config-templates/settings.json"))).toBe(false);
});
it("filtered write does not delete previously written artifacts", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
// First: full write
await runGet(["get", "m1l1", "--json"]);
const { existsSync } = await import("node:fs");
expect(existsSync(join(projectRoot, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(projectRoot, ".claude/prompts/plan.md"))).toBe(true);
// Second: filtered write — should NOT delete prompts
await runGet(["get", "m1l1", "--type", "skills", "--name", "code-review", "--json"]);
// The filtered skill should still exist
expect(existsSync(join(projectRoot, ".claude/skills/code-review/SKILL.md"))).toBe(true);
// Previously written prompts should NOT be deleted
expect(existsSync(join(projectRoot, ".claude/prompts/plan.md"))).toBe(true);
// Other skills should NOT be deleted either
expect(existsSync(join(projectRoot, ".claude/skills/debugging/SKILL.md"))).toBe(true);
});
});