@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.
- package/README.md +59 -0
- package/dist/index.mjs +556 -74
- 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.
|
|
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
|
-
|
|
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
|
|
4478
|
+
renameSync as renameSync3,
|
|
4473
4479
|
rmdirSync as rmdirSync2,
|
|
4474
|
-
rmSync as
|
|
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
|
|
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
|
-
|
|
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 (
|
|
4594
|
-
const
|
|
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 (
|
|
4599
|
-
const
|
|
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 } =
|
|
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 (
|
|
4645
|
-
const
|
|
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 } =
|
|
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
|
-
|
|
4687
|
-
if (!
|
|
4688
|
-
|
|
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
|
|
4717
|
-
|
|
4718
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5211
|
+
rmSync4(join6(skillDir, relPath), { force: true });
|
|
5133
5212
|
}
|
|
5134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5258
|
+
rmSync4(from, { force: true });
|
|
5180
5259
|
return true;
|
|
5181
5260
|
}
|
|
5182
5261
|
mkdirSync5(dirname4(to), { recursive: true });
|
|
5183
5262
|
try {
|
|
5184
|
-
|
|
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
|
-
|
|
5271
|
+
renameSync3(tmp, to);
|
|
5193
5272
|
try {
|
|
5194
|
-
|
|
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
|
-
|
|
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);
|