@przeprogramowani/10x-cli 1.7.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 +73 -0
- package/dist/index.mjs +608 -75
- 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
|
|
@@ -69,6 +71,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
|
|
|
69
71
|
| `--name <name>` | Filter by artifact name (requires `--type`) |
|
|
70
72
|
| `--dry-run` | Show what would be written without touching the filesystem |
|
|
71
73
|
| `--course <slug>` | Override the course slug (default: `10xdevs3`) |
|
|
74
|
+
| `--no-course-rules` | Skip the course rules block in your rules file (`CLAUDE.md`/`AGENTS.md`); strips an existing one. Use `--course-rules` to re-enable. |
|
|
72
75
|
|
|
73
76
|
#### Examples
|
|
74
77
|
|
|
@@ -88,8 +91,78 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
|
|
|
88
91
|
|
|
89
92
|
# Use with a different AI coding tool
|
|
90
93
|
10x get m1l1 --tool cursor
|
|
94
|
+
|
|
95
|
+
# Skip the course rules block (use only your rules). Persisted across runs;
|
|
96
|
+
# a previously-applied block is stripped. Re-enable later with --course-rules.
|
|
97
|
+
10x get m1l1 --no-course-rules
|
|
98
|
+
10x get m1l2 --course-rules
|
|
99
|
+
|
|
100
|
+
# An explicit rules request always applies, even with the opt-out persisted
|
|
101
|
+
10x get m1l1 --type rules
|
|
91
102
|
```
|
|
92
103
|
|
|
104
|
+
> The `--no-course-rules` / `--course-rules` choice is saved as `courseRules`
|
|
105
|
+
> in `config.json` and applies to subsequent plain `10x get` runs. An explicit
|
|
106
|
+
> `--type rules` request overrides the opt-out for that run. Skills, prompts,
|
|
107
|
+
> and config-templates are unaffected.
|
|
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
|
+
|
|
93
166
|
### Global Flags
|
|
94
167
|
|
|
95
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",
|
|
@@ -2992,6 +2992,9 @@ function readToolConfig() {
|
|
|
2992
2992
|
if (parsed.acknowledgedOrphans !== undefined && (!Array.isArray(parsed.acknowledgedOrphans) || !parsed.acknowledgedOrphans.every((x2) => typeof x2 === "string"))) {
|
|
2993
2993
|
delete parsed.acknowledgedOrphans;
|
|
2994
2994
|
}
|
|
2995
|
+
if (parsed.courseRules !== undefined && typeof parsed.courseRules !== "boolean") {
|
|
2996
|
+
delete parsed.courseRules;
|
|
2997
|
+
}
|
|
2995
2998
|
return parsed;
|
|
2996
2999
|
} catch {
|
|
2997
3000
|
return null;
|
|
@@ -3004,6 +3007,14 @@ function saveToolConfig(config) {
|
|
|
3004
3007
|
}
|
|
3005
3008
|
writeJsonAtomic(toolConfigPath(), config);
|
|
3006
3009
|
}
|
|
3010
|
+
function updateToolConfig(patch) {
|
|
3011
|
+
const current = readToolConfig();
|
|
3012
|
+
const merged = { ...current, ...patch };
|
|
3013
|
+
if (typeof merged.tool !== "string") {
|
|
3014
|
+
throw new Error("updateToolConfig: refusing to persist a config without a `tool` — " + "no prior config exists and the patch did not seed one.");
|
|
3015
|
+
}
|
|
3016
|
+
saveToolConfig(merged);
|
|
3017
|
+
}
|
|
3007
3018
|
|
|
3008
3019
|
// src/lib/api-client.ts
|
|
3009
3020
|
var DEFAULT_API_BASE = "https://10x-toolkit-api.przeprogramowani.workers.dev";
|
|
@@ -4213,7 +4224,7 @@ function parseModuleRef(raw) {
|
|
|
4213
4224
|
|
|
4214
4225
|
// src/lib/manifest.ts
|
|
4215
4226
|
import { createHash as createHash2 } from "node:crypto";
|
|
4216
|
-
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";
|
|
4217
4228
|
import { dirname as dirname2, join as join3 } from "node:path";
|
|
4218
4229
|
var MANIFEST_FILENAME = ".10x-cli-manifest.json";
|
|
4219
4230
|
var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
|
|
@@ -4247,8 +4258,11 @@ function writeManifest(dir, manifest) {
|
|
|
4247
4258
|
}
|
|
4248
4259
|
const manifestPath = join3(dir, MANIFEST_FILENAME);
|
|
4249
4260
|
mkdirSync3(dirname2(manifestPath), { recursive: true });
|
|
4250
|
-
|
|
4261
|
+
const tmp = `${manifestPath}.tmp`;
|
|
4262
|
+
rmSync2(tmp, { force: true });
|
|
4263
|
+
writeFileSync2(tmp, `${JSON.stringify(manifest, null, 2)}
|
|
4251
4264
|
`);
|
|
4265
|
+
renameSync2(tmp, manifestPath);
|
|
4252
4266
|
}
|
|
4253
4267
|
function isManifest(value) {
|
|
4254
4268
|
if (typeof value !== "object" || value === null)
|
|
@@ -4307,6 +4321,9 @@ function isLessonFilesEntry(value) {
|
|
|
4307
4321
|
if (!isStringArray(skill["files"]))
|
|
4308
4322
|
return false;
|
|
4309
4323
|
}
|
|
4324
|
+
if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
|
|
4325
|
+
return false;
|
|
4326
|
+
}
|
|
4310
4327
|
return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
|
|
4311
4328
|
}
|
|
4312
4329
|
function isLessonsRecord(value) {
|
|
@@ -4458,9 +4475,9 @@ import {
|
|
|
4458
4475
|
lstatSync,
|
|
4459
4476
|
mkdirSync as mkdirSync5,
|
|
4460
4477
|
readFileSync as readFileSync5,
|
|
4461
|
-
renameSync as
|
|
4478
|
+
renameSync as renameSync3,
|
|
4462
4479
|
rmdirSync as rmdirSync2,
|
|
4463
|
-
rmSync as
|
|
4480
|
+
rmSync as rmSync4,
|
|
4464
4481
|
statSync,
|
|
4465
4482
|
writeFileSync as writeFileSync4
|
|
4466
4483
|
} from "node:fs";
|
|
@@ -4551,7 +4568,7 @@ import {
|
|
|
4551
4568
|
readFileSync as readFileSync4,
|
|
4552
4569
|
readdirSync,
|
|
4553
4570
|
rmdirSync,
|
|
4554
|
-
rmSync as
|
|
4571
|
+
rmSync as rmSync3,
|
|
4555
4572
|
writeFileSync as writeFileSync3
|
|
4556
4573
|
} from "node:fs";
|
|
4557
4574
|
import { dirname as dirname3, join as join5 } from "node:path";
|
|
@@ -4563,30 +4580,25 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4563
4580
|
const course = options.course ?? DEFAULT_COURSE;
|
|
4564
4581
|
const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
|
|
4565
4582
|
const onConflict = options.onConflict;
|
|
4583
|
+
const applyCourseRules = options.applyCourseRules !== false;
|
|
4566
4584
|
const manifestDir = join5(projectRoot, profile.manifestDir);
|
|
4567
4585
|
const prevManifest = readManifest(manifestDir);
|
|
4568
|
-
|
|
4569
|
-
assertSafeName(skill.name, "skill");
|
|
4570
|
-
for (const file of skill.files) {
|
|
4571
|
-
assertSafeSkillFilePath(file.path, skill.name);
|
|
4572
|
-
}
|
|
4573
|
-
}
|
|
4574
|
-
for (const prompt of bundle.prompts)
|
|
4575
|
-
assertSafeName(prompt.name, "prompt");
|
|
4576
|
-
for (const config of bundle.configs)
|
|
4577
|
-
assertSafeName(config.name, "config");
|
|
4586
|
+
const plan = planBundle(bundle, projectRoot, { profile, applyCourseRules });
|
|
4578
4587
|
const nextSkillHashes = {};
|
|
4579
4588
|
const nextPromptHashes = {};
|
|
4580
4589
|
const skills = [];
|
|
4581
|
-
for (
|
|
4582
|
-
const
|
|
4590
|
+
for (let si = 0;si < bundle.skills.length; si++) {
|
|
4591
|
+
const skill = bundle.skills[si];
|
|
4592
|
+
const planSkill = plan.skills[si];
|
|
4583
4593
|
const prevEntry = prevManifest?.files.skills[skill.name];
|
|
4584
4594
|
const fileWrites = [];
|
|
4585
4595
|
const skillHashes = {};
|
|
4586
|
-
for (
|
|
4587
|
-
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;
|
|
4588
4600
|
const storedHash = prevEntry?.contentHashes?.[file.path];
|
|
4589
|
-
const { action, isConflict } =
|
|
4601
|
+
const { action, isConflict } = planFile;
|
|
4590
4602
|
let finalAction = action;
|
|
4591
4603
|
let userBackupPath;
|
|
4592
4604
|
if (isConflict) {
|
|
@@ -4629,11 +4641,13 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4629
4641
|
skills.push({ name: skill.name, files: fileWrites });
|
|
4630
4642
|
}
|
|
4631
4643
|
const prompts = [];
|
|
4632
|
-
for (
|
|
4633
|
-
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;
|
|
4634
4648
|
const promptFilename = `${prompt.name}.md`;
|
|
4635
4649
|
const storedHash = prevManifest?.files.promptHashes?.[promptFilename];
|
|
4636
|
-
const { action, isConflict } =
|
|
4650
|
+
const { action, isConflict } = planPrompt;
|
|
4637
4651
|
let finalAction = action;
|
|
4638
4652
|
let userBackupPath;
|
|
4639
4653
|
if (isConflict) {
|
|
@@ -4671,32 +4685,16 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4671
4685
|
}
|
|
4672
4686
|
const rulesFilePath = join5(projectRoot, profile.rulesFile);
|
|
4673
4687
|
const existingRules = readFileOrEmpty(rulesFilePath);
|
|
4674
|
-
|
|
4675
|
-
if (
|
|
4676
|
-
|
|
4677
|
-
} else {
|
|
4678
|
-
const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
|
|
4679
|
-
|
|
4680
|
-
`);
|
|
4681
|
-
const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
|
|
4682
|
-
if (newRules === existingRules) {
|
|
4683
|
-
rulesAction = "unchanged";
|
|
4684
|
-
} else if (existingRules.length === 0) {
|
|
4685
|
-
rulesAction = "created";
|
|
4686
|
-
} else {
|
|
4687
|
-
rulesAction = "updated";
|
|
4688
|
-
}
|
|
4689
|
-
if (!dryRun && rulesAction !== "unchanged") {
|
|
4690
|
-
writeFileAt(rulesFilePath, newRules);
|
|
4691
|
-
}
|
|
4688
|
+
const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
|
|
4689
|
+
if (!dryRun && rulesAction !== "unchanged") {
|
|
4690
|
+
writeFileAt(rulesFilePath, newRulesContent);
|
|
4692
4691
|
}
|
|
4693
|
-
const configs = bundle.configs.map((config) => {
|
|
4694
|
-
const
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
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);
|
|
4698
4696
|
}
|
|
4699
|
-
return { name: config.name, path:
|
|
4697
|
+
return { name: config.name, path: planConfig.path, action: planConfig.action };
|
|
4700
4698
|
});
|
|
4701
4699
|
const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
|
|
4702
4700
|
const removalResult = {
|
|
@@ -4707,31 +4705,33 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4707
4705
|
for (const entry of removed.skillDirs) {
|
|
4708
4706
|
removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
|
|
4709
4707
|
if (!dryRun && !partial)
|
|
4710
|
-
|
|
4708
|
+
rmSync3(entry.path, { recursive: true, force: true });
|
|
4711
4709
|
}
|
|
4712
4710
|
for (const entry of removed.skillFiles) {
|
|
4713
4711
|
removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
|
|
4714
4712
|
if (!dryRun && !partial) {
|
|
4715
|
-
|
|
4713
|
+
rmSync3(entry.path, { force: true });
|
|
4716
4714
|
removeEmptyParentDirs(entry.path, entry.skillDirAbs);
|
|
4717
4715
|
}
|
|
4718
4716
|
}
|
|
4719
4717
|
for (const entry of removed.prompts) {
|
|
4720
4718
|
removalResult.prompts.push({ name: entry.name, path: entry.path, action: "removed" });
|
|
4721
4719
|
if (!dryRun && !partial)
|
|
4722
|
-
|
|
4720
|
+
rmSync3(entry.path, { force: true });
|
|
4723
4721
|
}
|
|
4724
4722
|
for (const entry of removed.configs) {
|
|
4725
4723
|
removalResult.configs.push({ name: entry.name, path: entry.path, action: "removed" });
|
|
4726
4724
|
if (!dryRun && !partial)
|
|
4727
|
-
|
|
4725
|
+
rmSync3(entry.path, { force: true });
|
|
4728
4726
|
}
|
|
4729
4727
|
if (!dryRun && !partial) {
|
|
4728
|
+
const catalogContentHash = options.catalogContentHash ?? prevManifest?.lessons?.[bundle.lessonId]?.catalogContentHash;
|
|
4730
4729
|
const newLessonEntry = {
|
|
4731
4730
|
appliedAt: new Date().toISOString(),
|
|
4732
4731
|
skills: Object.fromEntries(bundle.skills.map((s) => [s.name, { files: s.files.map((f2) => f2.path) }])),
|
|
4733
4732
|
prompts: bundle.prompts.map((p2) => `${p2.name}.md`),
|
|
4734
|
-
configs: bundle.configs.map((c2) => c2.name)
|
|
4733
|
+
configs: bundle.configs.map((c2) => c2.name),
|
|
4734
|
+
...catalogContentHash !== undefined ? { catalogContentHash } : {}
|
|
4735
4735
|
};
|
|
4736
4736
|
let baseLessons = {};
|
|
4737
4737
|
if (prevManifest && !prevManifest.lessons) {
|
|
@@ -4791,6 +4791,107 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4791
4791
|
removals: removalResult
|
|
4792
4792
|
};
|
|
4793
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
|
+
}
|
|
4794
4895
|
function computeFileAction(filePath, newContent, storedHash) {
|
|
4795
4896
|
if (!existsSync5(filePath))
|
|
4796
4897
|
return { action: "created", isConflict: false };
|
|
@@ -5080,7 +5181,7 @@ function migrateArtifacts(projectRoot, orphan, newProfile) {
|
|
|
5080
5181
|
}
|
|
5081
5182
|
}
|
|
5082
5183
|
summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
|
|
5083
|
-
|
|
5184
|
+
rmSync4(orphan.manifestPath, { force: true });
|
|
5084
5185
|
tryRemoveEmptyDir(join6(projectRoot, oldProfile.manifestDir));
|
|
5085
5186
|
return summary;
|
|
5086
5187
|
}
|
|
@@ -5107,9 +5208,9 @@ function deleteArtifacts(projectRoot, orphan) {
|
|
|
5107
5208
|
});
|
|
5108
5209
|
continue;
|
|
5109
5210
|
}
|
|
5110
|
-
|
|
5211
|
+
rmSync4(join6(skillDir, relPath), { force: true });
|
|
5111
5212
|
}
|
|
5112
|
-
|
|
5213
|
+
rmSync4(skillDir, { recursive: true, force: true });
|
|
5113
5214
|
summary.movedOrRemoved.skills.push(skillName);
|
|
5114
5215
|
}
|
|
5115
5216
|
for (const promptFile of orphan.manifest.files.prompts) {
|
|
@@ -5118,7 +5219,7 @@ function deleteArtifacts(projectRoot, orphan) {
|
|
|
5118
5219
|
continue;
|
|
5119
5220
|
}
|
|
5120
5221
|
const promptName = promptFile.replace(/\.md$/, "");
|
|
5121
|
-
|
|
5222
|
+
rmSync4(join6(projectRoot, oldProfile.promptPath(promptName)), { force: true });
|
|
5122
5223
|
summary.movedOrRemoved.prompts.push(promptFile);
|
|
5123
5224
|
}
|
|
5124
5225
|
for (const configFile of orphan.manifest.files.configs) {
|
|
@@ -5126,11 +5227,11 @@ function deleteArtifacts(projectRoot, orphan) {
|
|
|
5126
5227
|
summary.skipped.push({ path: configFile, reason: "unsafe name in manifest" });
|
|
5127
5228
|
continue;
|
|
5128
5229
|
}
|
|
5129
|
-
|
|
5230
|
+
rmSync4(join6(projectRoot, oldProfile.configPath(configFile)), { force: true });
|
|
5130
5231
|
summary.movedOrRemoved.configs.push(configFile);
|
|
5131
5232
|
}
|
|
5132
5233
|
summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
|
|
5133
|
-
|
|
5234
|
+
rmSync4(orphan.manifestPath, { force: true });
|
|
5134
5235
|
const manifestDirAbs = join6(projectRoot, oldProfile.manifestDir);
|
|
5135
5236
|
tryRemoveEmptyDir(join6(manifestDirAbs, "skills"));
|
|
5136
5237
|
tryRemoveEmptyDir(join6(manifestDirAbs, "prompts"));
|
|
@@ -5154,12 +5255,12 @@ function moveIfSafe(from, to, skipped) {
|
|
|
5154
5255
|
});
|
|
5155
5256
|
return false;
|
|
5156
5257
|
}
|
|
5157
|
-
|
|
5258
|
+
rmSync4(from, { force: true });
|
|
5158
5259
|
return true;
|
|
5159
5260
|
}
|
|
5160
5261
|
mkdirSync5(dirname4(to), { recursive: true });
|
|
5161
5262
|
try {
|
|
5162
|
-
|
|
5263
|
+
renameSync3(from, to);
|
|
5163
5264
|
} catch (err) {
|
|
5164
5265
|
const code = err?.code;
|
|
5165
5266
|
if (code !== "EXDEV")
|
|
@@ -5167,9 +5268,9 @@ function moveIfSafe(from, to, skipped) {
|
|
|
5167
5268
|
const bytes = readFileSync5(from);
|
|
5168
5269
|
const tmp = `${to}.tmp`;
|
|
5169
5270
|
writeFileSync4(tmp, bytes);
|
|
5170
|
-
|
|
5271
|
+
renameSync3(tmp, to);
|
|
5171
5272
|
try {
|
|
5172
|
-
|
|
5273
|
+
rmSync4(from, { force: true });
|
|
5173
5274
|
} catch {
|
|
5174
5275
|
skipped.push({
|
|
5175
5276
|
path: from,
|
|
@@ -5191,7 +5292,7 @@ function stripSentinelFromRulesFile(projectRoot, oldProfile) {
|
|
|
5191
5292
|
if (!removed)
|
|
5192
5293
|
return false;
|
|
5193
5294
|
if (content.trim().length === 0) {
|
|
5194
|
-
|
|
5295
|
+
rmSync4(rulesPath, { force: true });
|
|
5195
5296
|
} else {
|
|
5196
5297
|
writeFileSync4(rulesPath, content);
|
|
5197
5298
|
}
|
|
@@ -5346,9 +5447,16 @@ var ARTIFACT_TYPES = ["skills", "prompts", "rules", "configs"];
|
|
|
5346
5447
|
function isArtifactType(s) {
|
|
5347
5448
|
return ARTIFACT_TYPES.includes(s);
|
|
5348
5449
|
}
|
|
5450
|
+
function resolveCourseRulesFlag(argv) {
|
|
5451
|
+
if (argv.includes("--no-course-rules"))
|
|
5452
|
+
return false;
|
|
5453
|
+
if (argv.includes("--course-rules"))
|
|
5454
|
+
return true;
|
|
5455
|
+
return;
|
|
5456
|
+
}
|
|
5349
5457
|
var DEFAULT_COURSE2 = "10xdevs3";
|
|
5350
5458
|
function registerGetCommand(cli) {
|
|
5351
|
-
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, windsurf, gemini, 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) => {
|
|
5459
|
+
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, windsurf, gemini, 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").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (ref, options) => {
|
|
5352
5460
|
const ctx = resolveContext(options);
|
|
5353
5461
|
await runGet(ctx, ref, options);
|
|
5354
5462
|
});
|
|
@@ -5371,10 +5479,18 @@ async function runGet(ctx, ref, options) {
|
|
|
5371
5479
|
const auth = await requireAuth(ctx);
|
|
5372
5480
|
const course = options.course ?? DEFAULT_COURSE2;
|
|
5373
5481
|
const profile = await resolveToolProfile(options.tool, process.cwd());
|
|
5482
|
+
const dryRun = options.dryRun === true;
|
|
5374
5483
|
const lang = options.lang ?? readToolConfig()?.lang ?? "en";
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5484
|
+
const explicitCourseRules = resolveCourseRulesFlag(process.argv);
|
|
5485
|
+
const resolvedCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
|
|
5486
|
+
if (!dryRun && (options.lang || explicitCourseRules !== undefined)) {
|
|
5487
|
+
const tool = readToolConfig()?.tool ?? profile.toolId;
|
|
5488
|
+
const patch = { tool };
|
|
5489
|
+
if (options.lang)
|
|
5490
|
+
patch.lang = options.lang;
|
|
5491
|
+
if (explicitCourseRules !== undefined)
|
|
5492
|
+
patch.courseRules = explicitCourseRules;
|
|
5493
|
+
updateToolConfig(patch);
|
|
5378
5494
|
}
|
|
5379
5495
|
if (options.print) {
|
|
5380
5496
|
await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options);
|
|
@@ -5405,15 +5521,18 @@ async function runGet(ctx, ref, options) {
|
|
|
5405
5521
|
if (prevManifest && prevManifest.manifestVersion === 2) {
|
|
5406
5522
|
showUpgradeNotice(isTTY);
|
|
5407
5523
|
}
|
|
5524
|
+
const applyCourseRules = isFiltered ? true : resolvedCourseRules;
|
|
5408
5525
|
const writeResult = await applyBundle(bundle, process.cwd(), {
|
|
5409
|
-
dryRun
|
|
5526
|
+
dryRun,
|
|
5410
5527
|
profile,
|
|
5411
5528
|
partial: isFiltered,
|
|
5412
|
-
onConflict: createConflictResolver(isTTY)
|
|
5529
|
+
onConflict: createConflictResolver(isTTY),
|
|
5530
|
+
applyCourseRules
|
|
5413
5531
|
});
|
|
5414
|
-
renderGetResult(ctx, bundle, writeResult,
|
|
5532
|
+
renderGetResult(ctx, bundle, writeResult, dryRun, profile, {
|
|
5415
5533
|
language: contentLang ?? lang,
|
|
5416
|
-
languageFallback: isFallback
|
|
5534
|
+
languageFallback: isFallback,
|
|
5535
|
+
applyCourseRules
|
|
5417
5536
|
});
|
|
5418
5537
|
}
|
|
5419
5538
|
function filterBundle(ctx, bundle, options) {
|
|
@@ -5553,7 +5672,12 @@ function formatAction(action) {
|
|
|
5553
5672
|
return action;
|
|
5554
5673
|
}
|
|
5555
5674
|
}
|
|
5556
|
-
function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
5675
|
+
function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
5676
|
+
language: "en",
|
|
5677
|
+
languageFallback: false,
|
|
5678
|
+
applyCourseRules: true
|
|
5679
|
+
}) {
|
|
5680
|
+
const applyCourseRules = langMeta.applyCourseRules;
|
|
5557
5681
|
const totalRemovals = writeResult.removals.skills.length + writeResult.removals.prompts.length + writeResult.removals.configs.length;
|
|
5558
5682
|
if (ctx.json) {
|
|
5559
5683
|
output(ctx, "", {
|
|
@@ -5574,7 +5698,7 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
5574
5698
|
counts: {
|
|
5575
5699
|
skills: writeResult.skills.length,
|
|
5576
5700
|
prompts: writeResult.prompts.length,
|
|
5577
|
-
rules: bundle.rules.length,
|
|
5701
|
+
rules: applyCourseRules ? bundle.rules.length : 0,
|
|
5578
5702
|
configs: writeResult.configs.length,
|
|
5579
5703
|
removals: totalRemovals
|
|
5580
5704
|
}
|
|
@@ -5602,7 +5726,13 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
5602
5726
|
for (const prompt of writeResult.prompts) {
|
|
5603
5727
|
lines.push(` [${formatAction(prompt.action)}] prompt ${prompt.path}`);
|
|
5604
5728
|
}
|
|
5605
|
-
if (
|
|
5729
|
+
if (!applyCourseRules) {
|
|
5730
|
+
if (writeResult.rules.action === "removed") {
|
|
5731
|
+
lines.push(` [removed] rules ${profile.rulesFile}`);
|
|
5732
|
+
} else {
|
|
5733
|
+
verbose(ctx, `course rules disabled — ${profile.rulesFile} left untouched`);
|
|
5734
|
+
}
|
|
5735
|
+
} else if (bundle.rules.length > 0) {
|
|
5606
5736
|
lines.push(` [${writeResult.rules.action}] rules ${profile.rulesFile} (${bundle.rules.length} block${bundle.rules.length === 1 ? "" : "s"})`);
|
|
5607
5737
|
}
|
|
5608
5738
|
for (const config of writeResult.configs) {
|
|
@@ -5756,6 +5886,408 @@ function renderModuleDetail(ctx, module) {
|
|
|
5756
5886
|
`), undefined);
|
|
5757
5887
|
}
|
|
5758
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
|
+
|
|
5759
6291
|
// src/index.ts
|
|
5760
6292
|
var cli = cac("10x");
|
|
5761
6293
|
cli.option("--json", "Output as JSON (auto-detected when piped)");
|
|
@@ -5763,6 +6295,7 @@ cli.option("--verbose", "Show detailed output on stderr");
|
|
|
5763
6295
|
registerAuthCommand(cli);
|
|
5764
6296
|
registerGetCommand(cli);
|
|
5765
6297
|
registerListCommand(cli);
|
|
6298
|
+
registerSyncCommand(cli);
|
|
5766
6299
|
registerDoctorCommand(cli);
|
|
5767
6300
|
cli.help();
|
|
5768
6301
|
cli.version(package_default.version);
|