@przeprogramowani/10x-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +112 -22
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -2271,7 +2271,7 @@ var cac = (name = "") => new CAC(name);
|
|
|
2271
2271
|
// package.json
|
|
2272
2272
|
var package_default = {
|
|
2273
2273
|
name: "@przeprogramowani/10x-cli",
|
|
2274
|
-
version: "0.
|
|
2274
|
+
version: "0.4.0",
|
|
2275
2275
|
description: "Open-source CLI for 10xDevs course content",
|
|
2276
2276
|
repository: {
|
|
2277
2277
|
type: "git",
|
|
@@ -3061,7 +3061,7 @@ async function request(method, path, body, options = {}) {
|
|
|
3061
3061
|
payload
|
|
3062
3062
|
};
|
|
3063
3063
|
}
|
|
3064
|
-
return { ok: true, status: response.status, data: parsed, responseHeaders: response.headers };
|
|
3064
|
+
return { ok: true, status: response.status, data: parsed, responseHeaders: response.headers, rawBody: text };
|
|
3065
3065
|
}
|
|
3066
3066
|
function apiGet(path, options = {}) {
|
|
3067
3067
|
return request("GET", path, undefined, options);
|
|
@@ -3449,7 +3449,7 @@ import { existsSync as existsSync2, accessSync, constants } from "node:fs";
|
|
|
3449
3449
|
import { join as join2 } from "node:path";
|
|
3450
3450
|
|
|
3451
3451
|
// src/lib/signing.ts
|
|
3452
|
-
import { createPublicKey, verify } from "node:crypto";
|
|
3452
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
3453
3453
|
|
|
3454
3454
|
class SignatureError extends Error {
|
|
3455
3455
|
constructor(message) {
|
|
@@ -3464,11 +3464,54 @@ var KEYSET = [
|
|
|
3464
3464
|
}
|
|
3465
3465
|
];
|
|
3466
3466
|
var REQUIRE_SIGNATURES = true;
|
|
3467
|
-
function
|
|
3468
|
-
const
|
|
3467
|
+
function loadEffectiveKeyset() {
|
|
3468
|
+
const override = process.env["BUNDLE_PUBLIC_KEYSET"];
|
|
3469
|
+
if (!override)
|
|
3470
|
+
return KEYSET;
|
|
3471
|
+
const apiBase = process.env["API_BASE_URL"];
|
|
3472
|
+
if (!apiBase)
|
|
3473
|
+
return KEYSET;
|
|
3474
|
+
let hostname;
|
|
3475
|
+
try {
|
|
3476
|
+
hostname = new URL(apiBase).hostname;
|
|
3477
|
+
} catch {
|
|
3478
|
+
return KEYSET;
|
|
3479
|
+
}
|
|
3480
|
+
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
3481
|
+
return KEYSET;
|
|
3482
|
+
}
|
|
3483
|
+
try {
|
|
3484
|
+
const parsed = JSON.parse(override);
|
|
3485
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
3486
|
+
return KEYSET;
|
|
3487
|
+
for (const k3 of parsed) {
|
|
3488
|
+
if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
|
|
3489
|
+
return KEYSET;
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
return parsed;
|
|
3493
|
+
} catch {
|
|
3494
|
+
return KEYSET;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
function sha256Hex(body) {
|
|
3498
|
+
return createHash("sha256").update(body).digest("hex");
|
|
3499
|
+
}
|
|
3500
|
+
function canonicalSigningString(keyId, hashHex) {
|
|
3501
|
+
return `v1:${keyId}:${hashHex}`;
|
|
3502
|
+
}
|
|
3503
|
+
function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
|
|
3504
|
+
verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
|
|
3505
|
+
}
|
|
3506
|
+
function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
|
|
3507
|
+
const key = keyset.find((k3) => k3.keyId === keyId);
|
|
3469
3508
|
if (!key) {
|
|
3470
3509
|
throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
|
|
3471
3510
|
}
|
|
3511
|
+
const computedHash = sha256Hex(rawBody);
|
|
3512
|
+
if (computedHash !== headerHash) {
|
|
3513
|
+
throw new SignatureError(`Bundle content hash mismatch (header=${headerHash.slice(0, 12)}…, computed=${computedHash.slice(0, 12)}…). ` + "The bundle may have been tampered with in transit. Do NOT use the content.");
|
|
3514
|
+
}
|
|
3472
3515
|
let pubKey;
|
|
3473
3516
|
try {
|
|
3474
3517
|
pubKey = createPublicKey({
|
|
@@ -3479,8 +3522,9 @@ function verifyBundleSignature(responseBody, signature, keyId) {
|
|
|
3479
3522
|
} catch {
|
|
3480
3523
|
throw new SignatureError("Failed to load the signing public key. Your CLI binary may be corrupted — reinstall it.");
|
|
3481
3524
|
}
|
|
3525
|
+
const canonical = canonicalSigningString(keyId, computedHash);
|
|
3482
3526
|
const sig = Buffer.from(signature, "base64");
|
|
3483
|
-
const valid = verify(null, Buffer.from(
|
|
3527
|
+
const valid = verify(null, Buffer.from(canonical), pubKey, sig);
|
|
3484
3528
|
if (!valid) {
|
|
3485
3529
|
throw new SignatureError("Bundle signature verification failed — the bundle may have been tampered with. " + "Do NOT use the content. Report this to the course team.");
|
|
3486
3530
|
}
|
|
@@ -3494,16 +3538,21 @@ function fetchModuleDetail(course, module, token, options = {}) {
|
|
|
3494
3538
|
return apiGet(`/api/modules/${encodeURIComponent(course)}/${module}`, { token, signal: options.signal });
|
|
3495
3539
|
}
|
|
3496
3540
|
async function fetchLesson(course, lessonId, token, options = {}) {
|
|
3497
|
-
const
|
|
3541
|
+
const params = new URLSearchParams;
|
|
3542
|
+
if (options.lang)
|
|
3543
|
+
params.set("lang", options.lang);
|
|
3544
|
+
const qs = params.toString();
|
|
3545
|
+
const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
|
|
3546
|
+
const result = await apiGet(path, { token, signal: options.signal });
|
|
3498
3547
|
if (!result.ok)
|
|
3499
3548
|
return result;
|
|
3500
3549
|
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
3501
3550
|
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
3502
|
-
|
|
3551
|
+
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
3552
|
+
if (signature && keyIdRaw && headerHash) {
|
|
3503
3553
|
const keyId = Number(keyIdRaw);
|
|
3504
|
-
const responseBody = JSON.stringify(result.data);
|
|
3505
3554
|
try {
|
|
3506
|
-
verifyBundleSignature(
|
|
3555
|
+
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
3507
3556
|
} catch (err) {
|
|
3508
3557
|
const message = err instanceof Error ? err.message : String(err);
|
|
3509
3558
|
return {
|
|
@@ -3513,6 +3562,13 @@ async function fetchLesson(course, lessonId, token, options = {}) {
|
|
|
3513
3562
|
error: message
|
|
3514
3563
|
};
|
|
3515
3564
|
}
|
|
3565
|
+
} else if (signature || keyIdRaw || headerHash) {
|
|
3566
|
+
return {
|
|
3567
|
+
ok: false,
|
|
3568
|
+
status: 0,
|
|
3569
|
+
code: "signature_error",
|
|
3570
|
+
error: "Bundle signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
3571
|
+
};
|
|
3516
3572
|
} else if (REQUIRE_SIGNATURES) {
|
|
3517
3573
|
return {
|
|
3518
3574
|
ok: false,
|
|
@@ -3528,17 +3584,19 @@ async function fetchLesson(course, lessonId, token, options = {}) {
|
|
|
3528
3584
|
}
|
|
3529
3585
|
async function fetchArtifact(course, lessonId, type, name, tool, token, options = {}) {
|
|
3530
3586
|
const params = new URLSearchParams({ tool });
|
|
3587
|
+
if (options.lang)
|
|
3588
|
+
params.set("lang", options.lang);
|
|
3531
3589
|
const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
|
|
3532
3590
|
const result = await apiGet(path, { token, signal: options.signal });
|
|
3533
3591
|
if (!result.ok)
|
|
3534
3592
|
return result;
|
|
3535
3593
|
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
3536
3594
|
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
3537
|
-
|
|
3595
|
+
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
3596
|
+
if (signature && keyIdRaw && headerHash) {
|
|
3538
3597
|
const keyId = Number(keyIdRaw);
|
|
3539
|
-
const responseBody = JSON.stringify(result.data);
|
|
3540
3598
|
try {
|
|
3541
|
-
verifyBundleSignature(
|
|
3599
|
+
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
3542
3600
|
} catch (err) {
|
|
3543
3601
|
const message = err instanceof Error ? err.message : String(err);
|
|
3544
3602
|
return {
|
|
@@ -3548,6 +3606,13 @@ async function fetchArtifact(course, lessonId, type, name, tool, token, options
|
|
|
3548
3606
|
error: message
|
|
3549
3607
|
};
|
|
3550
3608
|
}
|
|
3609
|
+
} else if (signature || keyIdRaw || headerHash) {
|
|
3610
|
+
return {
|
|
3611
|
+
ok: false,
|
|
3612
|
+
status: 0,
|
|
3613
|
+
code: "signature_error",
|
|
3614
|
+
error: "Artifact signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
3615
|
+
};
|
|
3551
3616
|
} else if (REQUIRE_SIGNATURES) {
|
|
3552
3617
|
return {
|
|
3553
3618
|
ok: false,
|
|
@@ -4263,7 +4328,7 @@ function isArtifactType(s) {
|
|
|
4263
4328
|
}
|
|
4264
4329
|
var DEFAULT_COURSE2 = "10xdevs3";
|
|
4265
4330
|
function registerGetCommand(cli) {
|
|
4266
|
-
cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, generic)").option("--print", "Print artifact content to stdout instead of writing to files").option("--type <type>", "Artifact type filter: skills, prompts, rules, configs").option("--name <name>", "Artifact name filter (requires --type)").action(async (ref, options) => {
|
|
4331
|
+
cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, generic)").option("--print", "Print artifact content to stdout instead of writing to files").option("--type <type>", "Artifact type filter: skills, prompts, rules, configs").option("--name <name>", "Artifact name filter (requires --type)").option("--lang <lang>", "Content language: en (default) or pl").action(async (ref, options) => {
|
|
4267
4332
|
const ctx = resolveContext(options);
|
|
4268
4333
|
await runGet(ctx, ref, options);
|
|
4269
4334
|
});
|
|
@@ -4279,18 +4344,32 @@ async function runGet(ctx, ref, options) {
|
|
|
4279
4344
|
if (options.type && !isArtifactType(options.type)) {
|
|
4280
4345
|
outputError(ctx, "invalid_type", `Unknown artifact type '${options.type}'.`, ExitCodes.USAGE, `Supported types: ${ARTIFACT_TYPES.join(", ")}`);
|
|
4281
4346
|
}
|
|
4347
|
+
const SUPPORTED_LANGS = ["en", "pl"];
|
|
4348
|
+
if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
|
|
4349
|
+
outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
|
|
4350
|
+
}
|
|
4282
4351
|
const auth = await requireAuth(ctx);
|
|
4283
4352
|
const course = options.course ?? DEFAULT_COURSE2;
|
|
4284
4353
|
const profile = await resolveToolProfile(options.tool);
|
|
4354
|
+
const lang = options.lang ?? readToolConfig()?.lang ?? "en";
|
|
4355
|
+
if (options.lang) {
|
|
4356
|
+
const existing = readToolConfig();
|
|
4357
|
+
saveToolConfig({ tool: existing?.tool ?? profile.toolId, lang: options.lang });
|
|
4358
|
+
}
|
|
4285
4359
|
if (options.print) {
|
|
4286
|
-
await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, options);
|
|
4360
|
+
await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options);
|
|
4287
4361
|
return;
|
|
4288
4362
|
}
|
|
4289
4363
|
verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
|
|
4290
|
-
const result = await fetchLesson(course, parsed.lessonId, auth.access_token);
|
|
4364
|
+
const result = await fetchLesson(course, parsed.lessonId, auth.access_token, { lang });
|
|
4291
4365
|
if (!result.ok) {
|
|
4292
4366
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
4293
4367
|
}
|
|
4368
|
+
const contentLang = result.responseHeaders.get("X-Content-Language");
|
|
4369
|
+
const isFallback = result.responseHeaders.get("X-Content-Fallback") === "true";
|
|
4370
|
+
if (isFallback && contentLang) {
|
|
4371
|
+
verbose(ctx, `${lang.toUpperCase()} not available for ${parsed.lessonId}, showing ${contentLang.toUpperCase()}.`);
|
|
4372
|
+
}
|
|
4294
4373
|
const orphanWarning = detectOrphanedArtifacts(process.cwd(), profile);
|
|
4295
4374
|
if (orphanWarning)
|
|
4296
4375
|
verbose(ctx, orphanWarning);
|
|
@@ -4301,7 +4380,10 @@ async function runGet(ctx, ref, options) {
|
|
|
4301
4380
|
profile,
|
|
4302
4381
|
partial: isFiltered
|
|
4303
4382
|
});
|
|
4304
|
-
renderGetResult(ctx, bundle, writeResult, options.dryRun === true, profile
|
|
4383
|
+
renderGetResult(ctx, bundle, writeResult, options.dryRun === true, profile, {
|
|
4384
|
+
language: contentLang ?? lang,
|
|
4385
|
+
languageFallback: isFallback
|
|
4386
|
+
});
|
|
4305
4387
|
}
|
|
4306
4388
|
function filterBundle(ctx, bundle, options) {
|
|
4307
4389
|
if (!options.type) {
|
|
@@ -4330,7 +4412,7 @@ function filterBundle(ctx, bundle, options) {
|
|
|
4330
4412
|
}
|
|
4331
4413
|
return { ...empty, [type]: bundle[type] };
|
|
4332
4414
|
}
|
|
4333
|
-
async function runPrintMode(ctx, lessonId, course, profile, token, options) {
|
|
4415
|
+
async function runPrintMode(ctx, lessonId, course, profile, token, lang, options) {
|
|
4334
4416
|
if (!options.type) {
|
|
4335
4417
|
outputError(ctx, "missing_type", "Specify --type for --print mode.", ExitCodes.USAGE, "Usage: 10x get m1l1 --print --type skills [--name code-review]");
|
|
4336
4418
|
}
|
|
@@ -4339,7 +4421,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, options) {
|
|
|
4339
4421
|
}
|
|
4340
4422
|
if (options.name) {
|
|
4341
4423
|
verbose(ctx, `fetching artifact ${course}/${lessonId}/${options.type}/${options.name}`);
|
|
4342
|
-
const result = await fetchArtifact(course, lessonId, options.type, options.name, profile.toolId, token);
|
|
4424
|
+
const result = await fetchArtifact(course, lessonId, options.type, options.name, profile.toolId, token, { lang });
|
|
4343
4425
|
if (!result.ok) {
|
|
4344
4426
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
4345
4427
|
}
|
|
@@ -4350,7 +4432,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, options) {
|
|
|
4350
4432
|
}
|
|
4351
4433
|
} else {
|
|
4352
4434
|
verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
|
|
4353
|
-
const result = await fetchLesson(course, lessonId, token);
|
|
4435
|
+
const result = await fetchLesson(course, lessonId, token, { lang });
|
|
4354
4436
|
if (!result.ok) {
|
|
4355
4437
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
4356
4438
|
}
|
|
@@ -4398,13 +4480,15 @@ function handleLessonError(ctx, status, code, error, payload) {
|
|
|
4398
4480
|
}
|
|
4399
4481
|
outputError(ctx, code || "lesson_fetch_failed", "Failed to fetch the lesson bundle.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
4400
4482
|
}
|
|
4401
|
-
function renderGetResult(ctx, bundle, writeResult, dryRun, profile) {
|
|
4483
|
+
function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = { language: "en", languageFallback: false }) {
|
|
4402
4484
|
if (ctx.json) {
|
|
4403
4485
|
output(ctx, "", {
|
|
4404
4486
|
lessonId: bundle.lessonId,
|
|
4405
4487
|
title: bundle.title,
|
|
4406
4488
|
summary: bundle.summary,
|
|
4407
4489
|
tool: profile.toolId,
|
|
4490
|
+
language: langMeta.language,
|
|
4491
|
+
languageFallback: langMeta.languageFallback,
|
|
4408
4492
|
dry_run: dryRun,
|
|
4409
4493
|
writes: {
|
|
4410
4494
|
skills: writeResult.skills,
|
|
@@ -4544,7 +4628,8 @@ function renderModuleDetail(ctx, module) {
|
|
|
4544
4628
|
lessonId: l2.lessonId,
|
|
4545
4629
|
lesson: l2.lesson,
|
|
4546
4630
|
title: l2.title,
|
|
4547
|
-
summary: l2.summary
|
|
4631
|
+
summary: l2.summary,
|
|
4632
|
+
availableLanguages: l2.availableLanguages ?? ["en"]
|
|
4548
4633
|
}))
|
|
4549
4634
|
});
|
|
4550
4635
|
return;
|
|
@@ -4562,6 +4647,11 @@ function renderModuleDetail(ctx, module) {
|
|
|
4562
4647
|
lines.push(` ${l2.summary}`);
|
|
4563
4648
|
}
|
|
4564
4649
|
}
|
|
4650
|
+
const hasMultiLang = module.lessons.some((l2) => l2.availableLanguages && l2.availableLanguages.length > 1);
|
|
4651
|
+
if (hasMultiLang) {
|
|
4652
|
+
lines.push("");
|
|
4653
|
+
lines.push("Language variants available. Use --lang pl to fetch Polish content.");
|
|
4654
|
+
}
|
|
4565
4655
|
output(ctx, lines.join(`
|
|
4566
4656
|
`), undefined);
|
|
4567
4657
|
}
|