@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.
Files changed (2) hide show
  1. package/dist/index.mjs +112 -22
  2. 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.3.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 verifyBundleSignature(responseBody, signature, keyId) {
3468
- const key = KEYSET.find((k3) => k3.keyId === keyId);
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(responseBody), pubKey, sig);
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 result = await apiGet(`/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}`, { token, signal: options.signal });
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
- if (signature && keyIdRaw) {
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(responseBody, signature, keyId);
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
- if (signature && keyIdRaw) {
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(responseBody, signature, keyId);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@przeprogramowani/10x-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Open-source CLI for 10xDevs course content",
5
5
  "repository": {
6
6
  "type": "git",