@przeprogramowani/10x-cli 1.8.0 → 1.9.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 (3) hide show
  1. package/README.md +59 -0
  2. package/dist/index.mjs +556 -74
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,6 +47,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
47
47
  10x auth # Authenticate with your email
48
48
  10x list # Browse available modules and lessons
49
49
  10x get m1l1 # Fetch and apply lesson artifacts
50
+ 10x sync # Update everything you've downloaded; show what changed
50
51
  10x doctor # Check everything is working
51
52
  ```
52
53
 
@@ -57,6 +58,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
57
58
  | `10x auth` | Magic-link login with your Circle-registered email |
58
59
  | `10x list` | Browse modules and lessons in your course |
59
60
  | `10x get <ref>` | Fetch a lesson and apply artifacts to your workspace |
61
+ | `10x sync` | Bulk-download / refresh lessons and report what changed upstream |
60
62
  | `10x doctor` | Diagnose auth, API connectivity, and local config |
61
63
 
62
64
  ### `10x get` Flags
@@ -104,6 +106,63 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
104
106
  > `--type rules` request overrides the opt-out for that run. Skills, prompts,
105
107
  > and config-templates are unaffected.
106
108
 
109
+ ### `10x sync`
110
+
111
+ `10x sync` keeps your downloaded lessons up to date and tells you **what changed
112
+ upstream** since you last fetched. By default it refreshes only the lessons you've
113
+ already downloaded; `--all` pulls every unlocked lesson at once.
114
+
115
+ Unchanged lessons are skipped **without a download** — the catalog advertises a
116
+ per-lesson `contentHash` that the CLI compares against what it last applied, so the
117
+ common "nothing changed" case is a single catalog request.
118
+
119
+ | Flag | Description |
120
+ |------|-------------|
121
+ | `--all` | Sync every unlocked lesson, not just the ones you've downloaded |
122
+ | `--module <m>` | Limit to one module (e.g. `m2` or `2`) |
123
+ | `--dry-run` | Preview what would change without writing anything |
124
+ | `--force` | Ignore the cheap-skip digest and overwrite local edits with upstream |
125
+ | `--tool <tool>` | AI coding tool (same set as `get`) |
126
+ | `--lang <lang>` | Content language: `en` (default) or `pl` |
127
+ | `--course <slug>` | Override the course slug (default: `10xdevs3`) |
128
+ | `--no-course-rules` | Skip the course rules block (same semantics as `get`) |
129
+
130
+ ```bash
131
+ # Refresh everything you've already downloaded; report what moved
132
+ 10x sync
133
+
134
+ # Pull every unlocked lesson in one shot (fresh project)
135
+ 10x sync --all
136
+
137
+ # Preview changes without writing
138
+ 10x sync --dry-run
139
+
140
+ # Only module 2
141
+ 10x sync --module m2
142
+
143
+ # Take all upstream updates, overwriting local edits
144
+ 10x sync --force
145
+ ```
146
+
147
+ The report classifies every resource as **upstream-updated**, **created**,
148
+ **unchanged**, **skipped (conflict)**, or **removed**. When a file you edited
149
+ locally also changed upstream, sync **keeps your edit** and prints the exact
150
+ command to take the update, e.g.:
151
+
152
+ ```
153
+ m2l3 — conflicts (1 skipped)
154
+ skipped skills/auth-skill (SKILL.md) — you edited it → 10x get m2l3 --type skills --name auth-skill
155
+ ```
156
+
157
+ Run that `10x get …` to take a single update, or `10x sync --force` to take them
158
+ all. **Change visibility covers skills and prompts** — configs are create-only
159
+ (never overwritten) and rules are sentinel-managed, so they aren't part of the
160
+ "what changed" report.
161
+
162
+ **Exit code is worst-outcome:** `0` when everything is clean/unchanged (a skipped
163
+ conflict is reported, not a failure), `1` if any lesson failed to fetch. The full
164
+ report is still emitted on a partial failure.
165
+
107
166
  ### Global Flags
108
167
 
109
168
  - `--json` — Machine-readable JSON output (auto-detected when piped)
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: "1.8.0",
2274
+ version: "1.9.0",
2275
2275
  description: "Open-source CLI for 10xDevs course content",
2276
2276
  repository: {
2277
2277
  type: "git",
@@ -4224,7 +4224,7 @@ function parseModuleRef(raw) {
4224
4224
 
4225
4225
  // src/lib/manifest.ts
4226
4226
  import { createHash as createHash2 } from "node:crypto";
4227
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
4227
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
4228
4228
  import { dirname as dirname2, join as join3 } from "node:path";
4229
4229
  var MANIFEST_FILENAME = ".10x-cli-manifest.json";
4230
4230
  var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
@@ -4258,8 +4258,11 @@ function writeManifest(dir, manifest) {
4258
4258
  }
4259
4259
  const manifestPath = join3(dir, MANIFEST_FILENAME);
4260
4260
  mkdirSync3(dirname2(manifestPath), { recursive: true });
4261
- writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
4261
+ const tmp = `${manifestPath}.tmp`;
4262
+ rmSync2(tmp, { force: true });
4263
+ writeFileSync2(tmp, `${JSON.stringify(manifest, null, 2)}
4262
4264
  `);
4265
+ renameSync2(tmp, manifestPath);
4263
4266
  }
4264
4267
  function isManifest(value) {
4265
4268
  if (typeof value !== "object" || value === null)
@@ -4318,6 +4321,9 @@ function isLessonFilesEntry(value) {
4318
4321
  if (!isStringArray(skill["files"]))
4319
4322
  return false;
4320
4323
  }
4324
+ if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
4325
+ return false;
4326
+ }
4321
4327
  return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
4322
4328
  }
4323
4329
  function isLessonsRecord(value) {
@@ -4469,9 +4475,9 @@ import {
4469
4475
  lstatSync,
4470
4476
  mkdirSync as mkdirSync5,
4471
4477
  readFileSync as readFileSync5,
4472
- renameSync as renameSync2,
4478
+ renameSync as renameSync3,
4473
4479
  rmdirSync as rmdirSync2,
4474
- rmSync as rmSync3,
4480
+ rmSync as rmSync4,
4475
4481
  statSync,
4476
4482
  writeFileSync as writeFileSync4
4477
4483
  } from "node:fs";
@@ -4562,7 +4568,7 @@ import {
4562
4568
  readFileSync as readFileSync4,
4563
4569
  readdirSync,
4564
4570
  rmdirSync,
4565
- rmSync as rmSync2,
4571
+ rmSync as rmSync3,
4566
4572
  writeFileSync as writeFileSync3
4567
4573
  } from "node:fs";
4568
4574
  import { dirname as dirname3, join as join5 } from "node:path";
@@ -4577,28 +4583,22 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4577
4583
  const applyCourseRules = options.applyCourseRules !== false;
4578
4584
  const manifestDir = join5(projectRoot, profile.manifestDir);
4579
4585
  const prevManifest = readManifest(manifestDir);
4580
- for (const skill of bundle.skills) {
4581
- assertSafeName(skill.name, "skill");
4582
- for (const file of skill.files) {
4583
- assertSafeSkillFilePath(file.path, skill.name);
4584
- }
4585
- }
4586
- for (const prompt of bundle.prompts)
4587
- assertSafeName(prompt.name, "prompt");
4588
- for (const config of bundle.configs)
4589
- assertSafeName(config.name, "config");
4586
+ const plan = planBundle(bundle, projectRoot, { profile, applyCourseRules });
4590
4587
  const nextSkillHashes = {};
4591
4588
  const nextPromptHashes = {};
4592
4589
  const skills = [];
4593
- for (const skill of bundle.skills) {
4594
- const skillDir = join5(projectRoot, profile.skillDir(skill.name));
4590
+ for (let si = 0;si < bundle.skills.length; si++) {
4591
+ const skill = bundle.skills[si];
4592
+ const planSkill = plan.skills[si];
4595
4593
  const prevEntry = prevManifest?.files.skills[skill.name];
4596
4594
  const fileWrites = [];
4597
4595
  const skillHashes = {};
4598
- for (const file of skill.files) {
4599
- const target = join5(skillDir, file.path);
4596
+ for (let fi = 0;fi < skill.files.length; fi++) {
4597
+ const file = skill.files[fi];
4598
+ const planFile = planSkill.files[fi];
4599
+ const target = planFile.path;
4600
4600
  const storedHash = prevEntry?.contentHashes?.[file.path];
4601
- const { action, isConflict } = computeFileAction(target, file.content, storedHash);
4601
+ const { action, isConflict } = planFile;
4602
4602
  let finalAction = action;
4603
4603
  let userBackupPath;
4604
4604
  if (isConflict) {
@@ -4641,11 +4641,13 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4641
4641
  skills.push({ name: skill.name, files: fileWrites });
4642
4642
  }
4643
4643
  const prompts = [];
4644
- for (const prompt of bundle.prompts) {
4645
- const target = join5(projectRoot, profile.promptPath(prompt.name));
4644
+ for (let pi = 0;pi < bundle.prompts.length; pi++) {
4645
+ const prompt = bundle.prompts[pi];
4646
+ const planPrompt = plan.prompts[pi];
4647
+ const target = planPrompt.path;
4646
4648
  const promptFilename = `${prompt.name}.md`;
4647
4649
  const storedHash = prevManifest?.files.promptHashes?.[promptFilename];
4648
- const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
4650
+ const { action, isConflict } = planPrompt;
4649
4651
  let finalAction = action;
4650
4652
  let userBackupPath;
4651
4653
  if (isConflict) {
@@ -4683,42 +4685,16 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4683
4685
  }
4684
4686
  const rulesFilePath = join5(projectRoot, profile.rulesFile);
4685
4687
  const existingRules = readFileOrEmpty(rulesFilePath);
4686
- let rulesAction;
4687
- if (!applyCourseRules) {
4688
- const { content: stripped, removed: removed2 } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
4689
- if (removed2 && stripped !== existingRules) {
4690
- rulesAction = "removed";
4691
- if (!dryRun) {
4692
- writeFileAt(rulesFilePath, stripped);
4693
- }
4694
- } else {
4695
- rulesAction = "unchanged";
4696
- }
4697
- } else if (bundle.rules.length === 0) {
4698
- rulesAction = "unchanged";
4699
- } else {
4700
- const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
4701
-
4702
- `);
4703
- const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
4704
- if (newRules === existingRules) {
4705
- rulesAction = "unchanged";
4706
- } else if (existingRules.length === 0) {
4707
- rulesAction = "created";
4708
- } else {
4709
- rulesAction = "updated";
4710
- }
4711
- if (!dryRun && rulesAction !== "unchanged") {
4712
- writeFileAt(rulesFilePath, newRules);
4713
- }
4688
+ const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
4689
+ if (!dryRun && rulesAction !== "unchanged") {
4690
+ writeFileAt(rulesFilePath, newRulesContent);
4714
4691
  }
4715
- const configs = bundle.configs.map((config) => {
4716
- const target = join5(projectRoot, profile.configPath(config.name));
4717
- const action = existsSync5(target) ? "skipped" : "created";
4718
- if (!dryRun && action === "created") {
4719
- writeFileAt(target, config.content);
4692
+ const configs = bundle.configs.map((config, ci) => {
4693
+ const planConfig = plan.configs[ci];
4694
+ if (!dryRun && planConfig.action === "created") {
4695
+ writeFileAt(planConfig.path, config.content);
4720
4696
  }
4721
- return { name: config.name, path: target, action };
4697
+ return { name: config.name, path: planConfig.path, action: planConfig.action };
4722
4698
  });
4723
4699
  const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
4724
4700
  const removalResult = {
@@ -4729,31 +4705,33 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4729
4705
  for (const entry of removed.skillDirs) {
4730
4706
  removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
4731
4707
  if (!dryRun && !partial)
4732
- rmSync2(entry.path, { recursive: true, force: true });
4708
+ rmSync3(entry.path, { recursive: true, force: true });
4733
4709
  }
4734
4710
  for (const entry of removed.skillFiles) {
4735
4711
  removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
4736
4712
  if (!dryRun && !partial) {
4737
- rmSync2(entry.path, { force: true });
4713
+ rmSync3(entry.path, { force: true });
4738
4714
  removeEmptyParentDirs(entry.path, entry.skillDirAbs);
4739
4715
  }
4740
4716
  }
4741
4717
  for (const entry of removed.prompts) {
4742
4718
  removalResult.prompts.push({ name: entry.name, path: entry.path, action: "removed" });
4743
4719
  if (!dryRun && !partial)
4744
- rmSync2(entry.path, { force: true });
4720
+ rmSync3(entry.path, { force: true });
4745
4721
  }
4746
4722
  for (const entry of removed.configs) {
4747
4723
  removalResult.configs.push({ name: entry.name, path: entry.path, action: "removed" });
4748
4724
  if (!dryRun && !partial)
4749
- rmSync2(entry.path, { force: true });
4725
+ rmSync3(entry.path, { force: true });
4750
4726
  }
4751
4727
  if (!dryRun && !partial) {
4728
+ const catalogContentHash = options.catalogContentHash ?? prevManifest?.lessons?.[bundle.lessonId]?.catalogContentHash;
4752
4729
  const newLessonEntry = {
4753
4730
  appliedAt: new Date().toISOString(),
4754
4731
  skills: Object.fromEntries(bundle.skills.map((s) => [s.name, { files: s.files.map((f2) => f2.path) }])),
4755
4732
  prompts: bundle.prompts.map((p2) => `${p2.name}.md`),
4756
- configs: bundle.configs.map((c2) => c2.name)
4733
+ configs: bundle.configs.map((c2) => c2.name),
4734
+ ...catalogContentHash !== undefined ? { catalogContentHash } : {}
4757
4735
  };
4758
4736
  let baseLessons = {};
4759
4737
  if (prevManifest && !prevManifest.lessons) {
@@ -4813,6 +4791,107 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4813
4791
  removals: removalResult
4814
4792
  };
4815
4793
  }
4794
+ function planBundle(bundle, projectRoot, options = {}) {
4795
+ const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
4796
+ const applyCourseRules = options.applyCourseRules !== false;
4797
+ for (const skill of bundle.skills) {
4798
+ assertSafeName(skill.name, "skill");
4799
+ for (const file of skill.files)
4800
+ assertSafeSkillFilePath(file.path, skill.name);
4801
+ }
4802
+ for (const prompt of bundle.prompts)
4803
+ assertSafeName(prompt.name, "prompt");
4804
+ for (const config of bundle.configs)
4805
+ assertSafeName(config.name, "config");
4806
+ const manifestDir = join5(projectRoot, profile.manifestDir);
4807
+ const prevManifest = readManifest(manifestDir);
4808
+ const skills = bundle.skills.map((skill) => {
4809
+ const skillDir = join5(projectRoot, profile.skillDir(skill.name));
4810
+ const prevEntry = prevManifest?.files.skills[skill.name];
4811
+ const files = skill.files.map((file) => {
4812
+ const target = join5(skillDir, file.path);
4813
+ const storedHash = prevEntry?.contentHashes?.[file.path];
4814
+ const { action, isConflict } = computeFileAction(target, file.content, storedHash);
4815
+ return {
4816
+ relativePath: file.path,
4817
+ path: target,
4818
+ action,
4819
+ isConflict,
4820
+ upstreamChanged: computeUpstreamChanged(file.content, storedHash, action)
4821
+ };
4822
+ });
4823
+ return { name: skill.name, files };
4824
+ });
4825
+ const prompts = bundle.prompts.map((prompt) => {
4826
+ const target = join5(projectRoot, profile.promptPath(prompt.name));
4827
+ const storedHash = prevManifest?.files.promptHashes?.[`${prompt.name}.md`];
4828
+ const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
4829
+ return {
4830
+ name: prompt.name,
4831
+ path: target,
4832
+ action,
4833
+ isConflict,
4834
+ upstreamChanged: computeUpstreamChanged(prompt.content, storedHash, action)
4835
+ };
4836
+ });
4837
+ const rulesFilePath = join5(projectRoot, profile.rulesFile);
4838
+ const existingRules = readFileOrEmpty(rulesFilePath);
4839
+ const { action: rulesAction } = planRules(existingRules, bundle, profile, applyCourseRules);
4840
+ const configs = bundle.configs.map((config) => {
4841
+ const target = join5(projectRoot, profile.configPath(config.name));
4842
+ const action = existsSync5(target) ? "skipped" : "created";
4843
+ return {
4844
+ name: config.name,
4845
+ path: target,
4846
+ action,
4847
+ isConflict: false,
4848
+ upstreamChanged: action === "created"
4849
+ };
4850
+ });
4851
+ const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
4852
+ const removals = {
4853
+ skills: [
4854
+ ...removed.skillDirs.map((e2) => ({ name: e2.name, path: e2.path })),
4855
+ ...removed.skillFiles.map((e2) => ({ name: e2.name, path: e2.path }))
4856
+ ],
4857
+ prompts: removed.prompts.map((e2) => ({ name: e2.name, path: e2.path })),
4858
+ configs: removed.configs.map((e2) => ({ name: e2.name, path: e2.path }))
4859
+ };
4860
+ return {
4861
+ skills,
4862
+ prompts,
4863
+ rules: { action: rulesAction, upstreamChanged: rulesAction !== "unchanged" },
4864
+ configs,
4865
+ removals
4866
+ };
4867
+ }
4868
+ function computeUpstreamChanged(newContent, storedHash, action) {
4869
+ if (storedHash === undefined)
4870
+ return action !== "unchanged";
4871
+ return contentHash(newContent) !== storedHash;
4872
+ }
4873
+ function planRules(existingRules, bundle, profile, applyCourseRules) {
4874
+ if (!applyCourseRules) {
4875
+ const { content: stripped, removed } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
4876
+ if (removed && stripped !== existingRules)
4877
+ return { action: "removed", content: stripped };
4878
+ return { action: "unchanged", content: existingRules };
4879
+ }
4880
+ if (bundle.rules.length === 0)
4881
+ return { action: "unchanged", content: existingRules };
4882
+ const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
4883
+
4884
+ `);
4885
+ const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
4886
+ let action;
4887
+ if (newRules === existingRules)
4888
+ action = "unchanged";
4889
+ else if (existingRules.length === 0)
4890
+ action = "created";
4891
+ else
4892
+ action = "updated";
4893
+ return { action, content: newRules };
4894
+ }
4816
4895
  function computeFileAction(filePath, newContent, storedHash) {
4817
4896
  if (!existsSync5(filePath))
4818
4897
  return { action: "created", isConflict: false };
@@ -5102,7 +5181,7 @@ function migrateArtifacts(projectRoot, orphan, newProfile) {
5102
5181
  }
5103
5182
  }
5104
5183
  summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
5105
- rmSync3(orphan.manifestPath, { force: true });
5184
+ rmSync4(orphan.manifestPath, { force: true });
5106
5185
  tryRemoveEmptyDir(join6(projectRoot, oldProfile.manifestDir));
5107
5186
  return summary;
5108
5187
  }
@@ -5129,9 +5208,9 @@ function deleteArtifacts(projectRoot, orphan) {
5129
5208
  });
5130
5209
  continue;
5131
5210
  }
5132
- rmSync3(join6(skillDir, relPath), { force: true });
5211
+ rmSync4(join6(skillDir, relPath), { force: true });
5133
5212
  }
5134
- rmSync3(skillDir, { recursive: true, force: true });
5213
+ rmSync4(skillDir, { recursive: true, force: true });
5135
5214
  summary.movedOrRemoved.skills.push(skillName);
5136
5215
  }
5137
5216
  for (const promptFile of orphan.manifest.files.prompts) {
@@ -5140,7 +5219,7 @@ function deleteArtifacts(projectRoot, orphan) {
5140
5219
  continue;
5141
5220
  }
5142
5221
  const promptName = promptFile.replace(/\.md$/, "");
5143
- rmSync3(join6(projectRoot, oldProfile.promptPath(promptName)), { force: true });
5222
+ rmSync4(join6(projectRoot, oldProfile.promptPath(promptName)), { force: true });
5144
5223
  summary.movedOrRemoved.prompts.push(promptFile);
5145
5224
  }
5146
5225
  for (const configFile of orphan.manifest.files.configs) {
@@ -5148,11 +5227,11 @@ function deleteArtifacts(projectRoot, orphan) {
5148
5227
  summary.skipped.push({ path: configFile, reason: "unsafe name in manifest" });
5149
5228
  continue;
5150
5229
  }
5151
- rmSync3(join6(projectRoot, oldProfile.configPath(configFile)), { force: true });
5230
+ rmSync4(join6(projectRoot, oldProfile.configPath(configFile)), { force: true });
5152
5231
  summary.movedOrRemoved.configs.push(configFile);
5153
5232
  }
5154
5233
  summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
5155
- rmSync3(orphan.manifestPath, { force: true });
5234
+ rmSync4(orphan.manifestPath, { force: true });
5156
5235
  const manifestDirAbs = join6(projectRoot, oldProfile.manifestDir);
5157
5236
  tryRemoveEmptyDir(join6(manifestDirAbs, "skills"));
5158
5237
  tryRemoveEmptyDir(join6(manifestDirAbs, "prompts"));
@@ -5176,12 +5255,12 @@ function moveIfSafe(from, to, skipped) {
5176
5255
  });
5177
5256
  return false;
5178
5257
  }
5179
- rmSync3(from, { force: true });
5258
+ rmSync4(from, { force: true });
5180
5259
  return true;
5181
5260
  }
5182
5261
  mkdirSync5(dirname4(to), { recursive: true });
5183
5262
  try {
5184
- renameSync2(from, to);
5263
+ renameSync3(from, to);
5185
5264
  } catch (err) {
5186
5265
  const code = err?.code;
5187
5266
  if (code !== "EXDEV")
@@ -5189,9 +5268,9 @@ function moveIfSafe(from, to, skipped) {
5189
5268
  const bytes = readFileSync5(from);
5190
5269
  const tmp = `${to}.tmp`;
5191
5270
  writeFileSync4(tmp, bytes);
5192
- renameSync2(tmp, to);
5271
+ renameSync3(tmp, to);
5193
5272
  try {
5194
- rmSync3(from, { force: true });
5273
+ rmSync4(from, { force: true });
5195
5274
  } catch {
5196
5275
  skipped.push({
5197
5276
  path: from,
@@ -5213,7 +5292,7 @@ function stripSentinelFromRulesFile(projectRoot, oldProfile) {
5213
5292
  if (!removed)
5214
5293
  return false;
5215
5294
  if (content.trim().length === 0) {
5216
- rmSync3(rulesPath, { force: true });
5295
+ rmSync4(rulesPath, { force: true });
5217
5296
  } else {
5218
5297
  writeFileSync4(rulesPath, content);
5219
5298
  }
@@ -5807,6 +5886,408 @@ function renderModuleDetail(ctx, module) {
5807
5886
  `), undefined);
5808
5887
  }
5809
5888
 
5889
+ // src/commands/sync.ts
5890
+ import { join as join8 } from "node:path";
5891
+ var DEFAULT_COURSE4 = "10xdevs3";
5892
+ var SUPPORTED_LANGS = ["en", "pl"];
5893
+ function registerSyncCommand(cli) {
5894
+ cli.command("sync", "Bulk-download and update lessons, reporting what changed upstream").option("--all", "Sync every unlocked lesson (default: only lessons you've already downloaded)").option("--dry-run", "Show what would change without writing").option("--force", "Ignore the cheap-skip digest and overwrite local edits with upstream").option("--module <module>", "Limit to one module (e.g. 'm2' or '2')").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, windsurf, gemini, generic)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (options) => {
5895
+ const ctx = resolveContext(options);
5896
+ await runSync(ctx, options);
5897
+ });
5898
+ }
5899
+ async function runSync(ctx, options) {
5900
+ if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
5901
+ outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
5902
+ }
5903
+ let moduleFilter;
5904
+ if (options.module !== undefined) {
5905
+ const parsed = parseModule(options.module);
5906
+ if (parsed === null) {
5907
+ outputError(ctx, "invalid_module", `'${options.module}' is not a valid module.`, ExitCodes.USAGE, "Use a module number like '2' or 'm2'.");
5908
+ }
5909
+ moduleFilter = parsed;
5910
+ }
5911
+ const auth = await requireAuth(ctx);
5912
+ const course = options.course ?? DEFAULT_COURSE4;
5913
+ const profile = await resolveToolProfile(options.tool, process.cwd());
5914
+ const dryRun = options.dryRun === true;
5915
+ const force = options.force === true;
5916
+ const lang = options.lang ?? readToolConfig()?.lang ?? "en";
5917
+ const explicitCourseRules = resolveCourseRulesFlag(process.argv);
5918
+ const applyCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
5919
+ verbose(ctx, `fetching catalog ${course}`);
5920
+ const catalogResult = await fetchCatalog(course, auth.access_token);
5921
+ if (!catalogResult.ok) {
5922
+ handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
5923
+ }
5924
+ const catalog = catalogResult.data;
5925
+ const moduleState = new Map;
5926
+ for (const mod of catalog.modules)
5927
+ moduleState.set(mod.module, mod.effectiveState);
5928
+ const exclusions = [];
5929
+ const unlocked = catalog.lessons.filter((lesson) => {
5930
+ if (moduleState.get(lesson.module) === "locked") {
5931
+ exclusions.push({ lessonId: lesson.lessonId, reason: `module ${lesson.module} is locked` });
5932
+ return false;
5933
+ }
5934
+ return true;
5935
+ });
5936
+ const manifest = readManifest(join8(process.cwd(), profile.manifestDir));
5937
+ const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : []);
5938
+ let targets = unlocked;
5939
+ if (moduleFilter !== undefined)
5940
+ targets = targets.filter((l2) => l2.module === moduleFilter);
5941
+ if (options.all !== true)
5942
+ targets = targets.filter((l2) => manifestLessonIds.has(l2.lessonId));
5943
+ targets = [...targets].sort((a2, b2) => a2.module - b2.module || a2.lesson - b2.lesson);
5944
+ const controller = new AbortController;
5945
+ const onSigint = () => controller.abort();
5946
+ process.once("SIGINT", onSigint);
5947
+ const outcomes = [];
5948
+ try {
5949
+ for (const lesson of targets) {
5950
+ if (controller.signal.aborted)
5951
+ break;
5952
+ outcomes.push(await syncLesson(ctx, lesson, {
5953
+ course,
5954
+ profile,
5955
+ lang,
5956
+ dryRun,
5957
+ force,
5958
+ applyCourseRules,
5959
+ token: auth.access_token,
5960
+ manifest,
5961
+ signal: controller.signal
5962
+ }));
5963
+ }
5964
+ } finally {
5965
+ process.removeListener("SIGINT", onSigint);
5966
+ }
5967
+ renderReport(ctx, profile, {
5968
+ course,
5969
+ dryRun,
5970
+ force,
5971
+ mode: options.all === true ? "all" : "downloaded",
5972
+ module: moduleFilter,
5973
+ outcomes,
5974
+ exclusions
5975
+ });
5976
+ if (outcomes.some((o) => o.status === "errored")) {
5977
+ process.exit(ExitCodes.ERROR);
5978
+ }
5979
+ }
5980
+ async function syncLesson(ctx, lesson, opts) {
5981
+ const stored = opts.manifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
5982
+ if (!opts.force && lesson.contentHash !== undefined && stored !== undefined && lesson.contentHash === stored) {
5983
+ verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
5984
+ return {
5985
+ lessonId: lesson.lessonId,
5986
+ title: lesson.title,
5987
+ fetched: false,
5988
+ status: "unchanged",
5989
+ resources: []
5990
+ };
5991
+ }
5992
+ verbose(ctx, `${lesson.lessonId}: fetching`);
5993
+ const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
5994
+ lang: opts.lang,
5995
+ tool: opts.profile.toolId,
5996
+ signal: opts.signal
5997
+ });
5998
+ if (!result.ok) {
5999
+ return {
6000
+ lessonId: lesson.lessonId,
6001
+ title: lesson.title,
6002
+ fetched: true,
6003
+ status: "errored",
6004
+ resources: [],
6005
+ error: {
6006
+ code: result.code || "lesson_fetch_failed",
6007
+ message: lessonErrorMessage(result.status, result.code, result.error),
6008
+ retry: `10x get ${lesson.lessonId}`
6009
+ }
6010
+ };
6011
+ }
6012
+ const bundle = result.data;
6013
+ if (opts.dryRun) {
6014
+ const plan = planBundle(bundle, process.cwd(), {
6015
+ profile: opts.profile,
6016
+ applyCourseRules: opts.applyCourseRules
6017
+ });
6018
+ const resources2 = classifyFromPlan(plan, lesson.lessonId, opts.force);
6019
+ return {
6020
+ lessonId: lesson.lessonId,
6021
+ title: lesson.title,
6022
+ fetched: true,
6023
+ status: lessonStatus(resources2),
6024
+ resources: resources2
6025
+ };
6026
+ }
6027
+ const onConflict = opts.force ? async () => "overwrite" : async () => "skip";
6028
+ const writeResult = await applyBundle(bundle, process.cwd(), {
6029
+ course: opts.course,
6030
+ profile: opts.profile,
6031
+ onConflict,
6032
+ applyCourseRules: opts.applyCourseRules,
6033
+ catalogContentHash: lesson.contentHash
6034
+ });
6035
+ const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
6036
+ return {
6037
+ lessonId: lesson.lessonId,
6038
+ title: lesson.title,
6039
+ fetched: true,
6040
+ status: lessonStatus(resources),
6041
+ resources
6042
+ };
6043
+ }
6044
+ function actionToBucket(action) {
6045
+ switch (action) {
6046
+ case "created":
6047
+ return "created";
6048
+ case "updated":
6049
+ case "conflict_overwritten":
6050
+ case "conflict_saved_user":
6051
+ return "upstream-updated";
6052
+ case "conflict_skipped":
6053
+ return "skipped-conflict";
6054
+ case "removed":
6055
+ return "removed";
6056
+ case "unchanged":
6057
+ case "skipped":
6058
+ default:
6059
+ return "unchanged";
6060
+ }
6061
+ }
6062
+ function remediation(lessonId, type, name) {
6063
+ return `10x get ${lessonId} --type ${type} --name ${name}`;
6064
+ }
6065
+ function classifyFromWriteResult(result, lessonId) {
6066
+ const out = [];
6067
+ for (const skill of result.skills) {
6068
+ for (const file of skill.files) {
6069
+ const bucket = actionToBucket(file.action);
6070
+ out.push({
6071
+ type: "skills",
6072
+ name: skill.name,
6073
+ file: file.path,
6074
+ bucket,
6075
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "skills", skill.name) } : {}
6076
+ });
6077
+ }
6078
+ }
6079
+ for (const prompt of result.prompts) {
6080
+ const bucket = actionToBucket(prompt.action);
6081
+ out.push({
6082
+ type: "prompts",
6083
+ name: prompt.name,
6084
+ bucket,
6085
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
6086
+ });
6087
+ }
6088
+ out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action) });
6089
+ for (const config of result.configs) {
6090
+ out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
6091
+ }
6092
+ for (const entry of result.removals.skills) {
6093
+ out.push({ type: "skills", name: entry.name, bucket: "removed" });
6094
+ }
6095
+ for (const entry of result.removals.prompts) {
6096
+ out.push({ type: "prompts", name: entry.name, bucket: "removed" });
6097
+ }
6098
+ for (const entry of result.removals.configs) {
6099
+ out.push({ type: "configs", name: entry.name, bucket: "removed" });
6100
+ }
6101
+ return out;
6102
+ }
6103
+ function classifyFromPlan(plan, lessonId, force) {
6104
+ const out = [];
6105
+ const conflictBucket = force ? "upstream-updated" : "skipped-conflict";
6106
+ for (const skill of plan.skills) {
6107
+ for (const file of skill.files) {
6108
+ const bucket = file.isConflict ? conflictBucket : actionToBucket(file.action);
6109
+ out.push({
6110
+ type: "skills",
6111
+ name: skill.name,
6112
+ file: file.relativePath,
6113
+ bucket,
6114
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "skills", skill.name) } : {}
6115
+ });
6116
+ }
6117
+ }
6118
+ for (const prompt of plan.prompts) {
6119
+ const bucket = prompt.isConflict ? conflictBucket : actionToBucket(prompt.action);
6120
+ out.push({
6121
+ type: "prompts",
6122
+ name: prompt.name,
6123
+ bucket,
6124
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
6125
+ });
6126
+ }
6127
+ out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(plan.rules.action) });
6128
+ for (const config of plan.configs) {
6129
+ out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
6130
+ }
6131
+ for (const entry of plan.removals.skills) {
6132
+ out.push({ type: "skills", name: entry.name, bucket: "removed" });
6133
+ }
6134
+ for (const entry of plan.removals.prompts) {
6135
+ out.push({ type: "prompts", name: entry.name, bucket: "removed" });
6136
+ }
6137
+ for (const entry of plan.removals.configs) {
6138
+ out.push({ type: "configs", name: entry.name, bucket: "removed" });
6139
+ }
6140
+ return out;
6141
+ }
6142
+ function lessonStatus(resources) {
6143
+ if (resources.some((r2) => r2.bucket === "created" || r2.bucket === "upstream-updated" || r2.bucket === "removed")) {
6144
+ return "updated";
6145
+ }
6146
+ if (resources.some((r2) => r2.bucket === "skipped-conflict"))
6147
+ return "conflicts";
6148
+ return "unchanged";
6149
+ }
6150
+ function countBuckets(outcomes) {
6151
+ const totals = {
6152
+ created: 0,
6153
+ "upstream-updated": 0,
6154
+ unchanged: 0,
6155
+ "skipped-conflict": 0,
6156
+ removed: 0
6157
+ };
6158
+ for (const o of outcomes) {
6159
+ for (const r2 of o.resources)
6160
+ totals[r2.bucket]++;
6161
+ }
6162
+ return totals;
6163
+ }
6164
+ function renderReport(ctx, profile, input) {
6165
+ const { outcomes, exclusions } = input;
6166
+ const buckets = countBuckets(outcomes);
6167
+ const lessonsErrored = outcomes.filter((o) => o.status === "errored").length;
6168
+ const lessonsConflicts = outcomes.filter((o) => o.status === "conflicts").length;
6169
+ if (ctx.json) {
6170
+ output(ctx, "", {
6171
+ course: input.course,
6172
+ tool: profile.toolId,
6173
+ dryRun: input.dryRun,
6174
+ force: input.force,
6175
+ mode: input.mode,
6176
+ module: input.module ?? null,
6177
+ lessons: outcomes,
6178
+ excluded: exclusions,
6179
+ totals: {
6180
+ lessons: outcomes.length,
6181
+ updated: outcomes.filter((o) => o.status === "updated").length,
6182
+ unchanged: outcomes.filter((o) => o.status === "unchanged").length,
6183
+ conflicts: lessonsConflicts,
6184
+ errored: lessonsErrored,
6185
+ excluded: exclusions.length,
6186
+ resources: {
6187
+ created: buckets.created,
6188
+ upstreamUpdated: buckets["upstream-updated"],
6189
+ unchanged: buckets.unchanged,
6190
+ skippedConflict: buckets["skipped-conflict"],
6191
+ removed: buckets.removed
6192
+ }
6193
+ }
6194
+ });
6195
+ return;
6196
+ }
6197
+ const lines = [];
6198
+ const verb = input.dryRun ? "Would sync" : "Synced";
6199
+ const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
6200
+ const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
6201
+ lines.push(`${verb} ${input.course} — ${scope}${moduleNote}:`);
6202
+ if (outcomes.length === 0) {
6203
+ lines.push(" (nothing to sync)");
6204
+ }
6205
+ for (const o of outcomes) {
6206
+ if (o.status === "errored") {
6207
+ lines.push(` ${o.lessonId} — error: ${o.error?.message ?? "failed"}`);
6208
+ if (o.error)
6209
+ lines.push(` → retry: ${o.error.retry}`);
6210
+ continue;
6211
+ }
6212
+ const summary = summarizeLesson(o);
6213
+ lines.push(` ${o.lessonId} — ${o.status}${summary ? ` (${summary})` : ""}`);
6214
+ for (const r2 of o.resources) {
6215
+ if (r2.bucket !== "skipped-conflict")
6216
+ continue;
6217
+ const label = r2.file ? `${r2.type}/${r2.name} (${r2.file})` : `${r2.type}/${r2.name}`;
6218
+ lines.push(` skipped ${label} — you edited it → ${r2.remediation}`);
6219
+ }
6220
+ }
6221
+ if (exclusions.length > 0) {
6222
+ lines.push("");
6223
+ lines.push("Excluded:");
6224
+ for (const ex of exclusions)
6225
+ lines.push(` ${ex.lessonId} — ${ex.reason}`);
6226
+ }
6227
+ lines.push("");
6228
+ lines.push(`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` + `${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` + `${buckets.removed} removed.`);
6229
+ if (lessonsConflicts > 0 && !input.force) {
6230
+ lines.push("To take all upstream updates over your local edits: 10x sync --force");
6231
+ }
6232
+ output(ctx, lines.join(`
6233
+ `), undefined);
6234
+ }
6235
+ function summarizeLesson(o) {
6236
+ const counts = {
6237
+ created: 0,
6238
+ "upstream-updated": 0,
6239
+ unchanged: 0,
6240
+ "skipped-conflict": 0,
6241
+ removed: 0
6242
+ };
6243
+ for (const r2 of o.resources)
6244
+ counts[r2.bucket]++;
6245
+ const parts = [];
6246
+ if (counts["upstream-updated"])
6247
+ parts.push(`${counts["upstream-updated"]} updated`);
6248
+ if (counts.created)
6249
+ parts.push(`${counts.created} new`);
6250
+ if (counts["skipped-conflict"])
6251
+ parts.push(`${counts["skipped-conflict"]} skipped`);
6252
+ if (counts.removed)
6253
+ parts.push(`${counts.removed} removed`);
6254
+ if (!o.fetched)
6255
+ parts.push("not downloaded");
6256
+ return parts.join(", ");
6257
+ }
6258
+ function handleCatalogError(ctx, status, code, error) {
6259
+ if (status === 401) {
6260
+ outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
6261
+ }
6262
+ if (status === 404) {
6263
+ outputError(ctx, "course_not_found", "Couldn't find that course.", ExitCodes.NOT_FOUND, "Check the --course slug, or omit it to use the default.");
6264
+ }
6265
+ if (status === 0) {
6266
+ outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run the command again.");
6267
+ }
6268
+ outputError(ctx, code || "catalog_fetch_failed", "Failed to fetch the course catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
6269
+ }
6270
+ function lessonErrorMessage(status, code, error) {
6271
+ if (status === 403)
6272
+ return "Module is locked.";
6273
+ if (status === 404)
6274
+ return "Lesson not found.";
6275
+ if (status === 401)
6276
+ return "Session expired.";
6277
+ if (status === 0)
6278
+ return "Network error reaching the API.";
6279
+ if (code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
6280
+ return "Bundle signature verification failed.";
6281
+ }
6282
+ return error ? `Fetch failed: ${error}` : "Fetch failed.";
6283
+ }
6284
+ function parseModule(value) {
6285
+ const match = /^m?(\d+)$/i.exec(value.trim());
6286
+ if (!match)
6287
+ return null;
6288
+ return Number(match[1]);
6289
+ }
6290
+
5810
6291
  // src/index.ts
5811
6292
  var cli = cac("10x");
5812
6293
  cli.option("--json", "Output as JSON (auto-detected when piped)");
@@ -5814,6 +6295,7 @@ cli.option("--verbose", "Show detailed output on stderr");
5814
6295
  registerAuthCommand(cli);
5815
6296
  registerGetCommand(cli);
5816
6297
  registerListCommand(cli);
6298
+ registerSyncCommand(cli);
5817
6299
  registerDoctorCommand(cli);
5818
6300
  cli.help();
5819
6301
  cli.version(package_default.version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@przeprogramowani/10x-cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Open-source CLI for 10xDevs course content",
5
5
  "repository": {
6
6
  "type": "git",