mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
d2733fd2a3
Final SHA write-back into Progress (phase 4 rows) + change.md → implemented.
528 lines
28 KiB
Markdown
528 lines
28 KiB
Markdown
# `10x sync` — Bulk Download & Update with Change Visibility — Implementation Plan
|
|
|
|
## Overview
|
|
|
|
Add a `10x sync` command that lets learners (1) download every unlocked lesson for a
|
|
course in one shot and (2) refresh artifacts they already downloaded, with a clear
|
|
report of **what changed upstream** and **what was left untouched** (and how to take
|
|
those updates). To make the "what changed" check cheap, the backend gains a per-lesson
|
|
`contentHash` on the catalog endpoint so the CLI can skip unchanged lessons without
|
|
downloading them.
|
|
|
|
This is a coordinated two-repo change: `10x-toolkit` (catalog digest, backend-first)
|
|
then `10x-cli` (`planBundle()` writer refactor → `10x sync` command → tests/docs).
|
|
|
|
## Current State Analysis
|
|
|
|
- **Enumeration is already one call.** `fetchCatalog` hits `GET /api/catalog/:course`
|
|
and returns all unlocked lessons across all modules in `catalog.lessons`
|
|
(`10x-cli/src/lib/api-content.ts:104-113`); locked-module lessons are omitted. The
|
|
evangelist's manual `list m0 / list m1 …` loop is redundant.
|
|
- **Re-apply is already safe + idempotent.** `applyBundle` does three-way SHA-256
|
|
conflict detection: clean upstream change applied, unchanged left alone, user-edit →
|
|
conflict resolver (non-TTY default `skip` — user work never silently lost)
|
|
(`10x-cli/src/lib/writer.ts:445-464`, `conflict-prompt.ts:4-40`).
|
|
- **The manifest already stores per-file content hashes** for skills and prompts
|
|
(`10x-cli/src/lib/manifest.ts:20-22, 24-29`) and a per-lesson ownership record
|
|
`lessons: Record<string, LessonFilesEntry>` whose keys are the iteration set for an
|
|
"update what I have" sweep (`manifest.ts:31-36`).
|
|
- **No cheap upstream change signal exists today.** The catalog carries no per-lesson
|
|
hash/etag/timestamp (`10x-toolkit/packages/api/src/routes/catalog.ts:14-27`); the
|
|
only real hash (`X-Bundle-Content-Hash`) is a request-time, variant-specific signing
|
|
header (`lessons.ts:153-161`). So "did this change?" currently requires downloading
|
|
the full bundle.
|
|
- **But the substrate exists at build time.** A per-artifact `contentHash` is computed
|
|
by `10x-toolkit/packages/course-content/scripts/transform-content.mjs:100-101` and
|
|
the schema already reserves the slot (`schemas/bundle.ts:6-8, 20-21`). The catalog is
|
|
built in the same run (`build/core.ts:160-183`), so a per-lesson digest is nearly
|
|
free to publish.
|
|
- **`computeFileAction` is module-private** (`writer.ts:445-464`); there is no exported
|
|
pure planner that returns per-file actions + conflict flags without writing or
|
|
prompting. `--dry-run` exists in `get` but in TTY mode `await`s `onConflict` before
|
|
the write guard (`writer.ts:189-197`), so a clean non-prompting preview needs a new
|
|
planner.
|
|
- **`handleLessonError` calls into `outputError` → `process.exit`**
|
|
(`get.ts:385-468`); reused raw in a loop it would abort the whole sweep on the first
|
|
error.
|
|
|
|
## Desired End State
|
|
|
|
A learner runs `10x sync` and sees their already-downloaded lessons refreshed with a
|
|
report like "3 lessons updated, 2 skills changed, 1 file skipped (you edited it — run
|
|
`10x get m2l3 --type skills --name auth-skill` to take the update), 8 unchanged". They
|
|
run `10x sync --all` to pull every unlocked lesson at once, and `10x sync --dry-run` to
|
|
preview changes without writing. Unchanged lessons are skipped **without** a download,
|
|
because the catalog now advertises a per-lesson `contentHash` the CLI compares against
|
|
what it last applied.
|
|
|
|
Verify: `bun test` passes in `10x-cli` (incl. new sync tests); `GET /api/catalog/10xdevs3`
|
|
returns a `contentHash` per lesson; a real `10x sync` against a local API shows the
|
|
cheap-skip and the actionable report.
|
|
|
|
### Key Discoveries
|
|
|
|
- One catalog call enumerates everything fetchable — `api-content.ts:104-113`.
|
|
- Safe re-apply is inherited for free — `writer.ts:445-464`, `conflict-prompt.ts:8`.
|
|
- Per-artifact build-time hash already exists — `transform-content.mjs:100-101`,
|
|
`schemas/bundle.ts:6-8`.
|
|
- Catalog handler spreads `catalog.lessons` raw, so a new schema field flows through
|
|
with no handler logic — `10x-toolkit/packages/api/src/routes/catalog.ts:79`.
|
|
- Course/module/lesson map + lock resolution — `course-content/src/courses/10xdevs3/index.ts:34-44`,
|
|
`packages/api/src/lib/module-state.ts:3-11`.
|
|
|
|
## What We're NOT Doing
|
|
|
|
- **No config/rules change reporting.** Configs aren't hashed (skip-on-exists) and
|
|
rules are sentinel-managed, not manifest-tracked. The change report covers skills +
|
|
prompts only. (The per-artifact build hashes for configs/rules exist and could be
|
|
folded into the lesson digest later — out of scope here.)
|
|
- **No per-`{tool,lang}` catalog digest.** One digest per lesson (an aggregate of the
|
|
version-mixed per-artifact hashes), not a map keyed by tool/lang. It changes on any
|
|
variant-affecting change (source edit or `SYSTEM_PROMPT_VERSION` bump), so it never
|
|
*under*-reports; the cost is occasional benign over-fetch for a given user's variant,
|
|
which the writer resolves as unchanged.
|
|
- **No `If-None-Match`/304 conditional-request layer**, no new bulk/diff/manifest
|
|
endpoint — the catalog field subsumes those for our needs.
|
|
- **No concurrency beyond a small bounded pool / sequential** — no retry/backoff
|
|
framework; the existing 30s per-call timeout and `rate_limited` mapping stand.
|
|
- **No change to `get`'s single-lesson contract** — `sync` is a new command.
|
|
|
|
## Implementation Approach
|
|
|
|
Backend-first so the CLI gets cheap change-detection from day one, but the CLI is
|
|
written **defensively**: `LessonSummary.contentHash` is optional, and when it's absent
|
|
(older backend) `sync` falls back to always-fetch. The catalog digest is a base-variant
|
|
"did the source content change?" fingerprint stored back into the manifest per lesson;
|
|
next sync compares new-catalog-digest vs stored-digest to decide skip-vs-fetch. The
|
|
writer gains a pure `planBundle()` so `sync` can classify and preview without writing or
|
|
prompting, and `applyBundle` consumes the same planner so behavior can't drift.
|
|
|
|
## Critical Implementation Details
|
|
|
|
- **The digest is an aggregate of existing per-artifact `contentHash`es, NOT a fresh
|
|
base-bundle hash.** Each published artifact already carries
|
|
`contentHash = sha256("<SYSTEM_PROMPT_VERSION>:" + source)` for skills/prompts/rules
|
|
(`transform-content.mjs:364,408`); configs are unhashed. The per-lesson catalog digest
|
|
= `sha256` over the lesson's artifact hashes in a deterministic order
|
|
(e.g. sorted `"<type>/<name>:<hash>"` lines). **Why not a plain base-bundle hash:** a
|
|
base-source-only hash does not move on a `SYSTEM_PROMPT_VERSION` bump, but such a bump
|
|
regenerates `universalContent` — the bytes non-Claude-tool users download — so a
|
|
base-only digest would *silently skip* that update (a false negative). The
|
|
version-mixed aggregate moves on source edits AND version bumps. Configs being excluded
|
|
is safe: the writer treats configs as create-only, so an upstream config change never
|
|
overwrites an existing file — there is nothing for `sync` to miss.
|
|
- **Failure-mode asymmetry — err toward over-fetching.** The digest is only a fetch/skip
|
|
gate; the "what changed" report always comes from the writer's real per-file hashing
|
|
after a fetch. A digest *false positive* (differs but content same) costs one wasted
|
|
fetch the writer then reports as unchanged — harmless. A digest *false negative* (same
|
|
but content changed) silently skips an update — the one outcome to design against. The
|
|
aggregate above is chosen specifically to avoid false negatives.
|
|
- **Digest-vs-digest, never digest-vs-written-files.** The CLI stores the exact
|
|
catalog-provided digest per lesson in the manifest on apply, and on the next sync
|
|
compares the *new* catalog digest against the *stored* catalog digest. It must never
|
|
compare the catalog digest against the per-file written hashes — those live in a
|
|
different (transformed, variant-specific) hash space and would mismatch every run.
|
|
- **`--force` and `--dry-run --force` must bypass the cheap-skip gate.** Because the
|
|
digest skip happens *before* fetch, `--force` cannot overwrite a locally-edited file
|
|
when upstream is unchanged unless it skips the gate and always fetches. `--force` ⇒
|
|
ignore the stored digest, fetch every targeted lesson, apply with the overwrite
|
|
resolver.
|
|
- **Cheap-skip reports upstream state, not local drift.** A digest match means "upstream
|
|
unchanged since last apply" and is reported `unchanged` without reading local files; it
|
|
intentionally does not detect or repair a user's local edits when upstream hasn't moved
|
|
(use `get` or `--force` to restore). A plain `get` between syncs does not refresh the
|
|
stored digest, so at most one redundant fetch can occur on the next sync (writer
|
|
confirms unchanged) — acceptable.
|
|
- **Ordering / cross-repo coupling.** `10x-cli`'s `src/generated/api-types.ts` is
|
|
generated from the deployed `/openapi.json` and must never be hand-edited
|
|
(`10x-cli/CLAUDE.md`). So Phase 2's type regeneration depends on Phase 1 being
|
|
deployed. Until then, `sync` works against a catalog without `contentHash` via the
|
|
always-fetch fallback.
|
|
- **Never `process.exit` mid-sweep.** `sync` needs its own per-lesson outcome
|
|
accumulator and emits exactly one aggregate envelope at the end; `handleLessonError`'s
|
|
exit-on-first-error path (`get.ts:385-468`) must not be reused raw inside the loop.
|
|
|
|
---
|
|
|
|
## Phase 1: Backend — per-lesson `contentHash` on the catalog (`10x-toolkit`)
|
|
|
|
### Overview
|
|
|
|
Publish a stable base-variant digest per lesson on `GET /api/catalog/:course` so the CLI
|
|
can detect changed lessons in one request.
|
|
|
|
### Changes Required:
|
|
|
|
#### 1. Compute + write the per-lesson digest in the transform step
|
|
|
|
**File**: `10x-toolkit/packages/course-content/scripts/transform-content.mjs` (`updateCatalogLanguages`, 615-628, + main flow ~740)
|
|
|
|
**Intent**: After transform has populated per-artifact `contentHash`es into each lesson
|
|
bundle, aggregate them into a per-lesson digest and write it onto each catalog lesson.
|
|
This is the natural home because (a) the artifact hashes only exist post-transform, (b)
|
|
this function already does a read-modify-write of `catalog.json` that preserves other
|
|
fields, and (c) the digest must be `SYSTEM_PROMPT_VERSION`-aware to avoid silently
|
|
skipping re-translations (see Critical Implementation Details). Runs in CI on publish;
|
|
local `build:lessons`-only runs leave `contentHash` absent → CLI always-fetch fallback.
|
|
|
|
**Contract**: For each catalog lesson, read its transformed bundle
|
|
(`<lessonId>.json`), collect the `contentHash` of every skill/prompt/rule, and set
|
|
`lesson.contentHash = sha256(sortedJoin("<type>/<name>:<hash>"))` (lowercase hex,
|
|
deterministic order). Configs have no hash and are excluded (safe — writer treats configs
|
|
as create-only). Preserve the existing `availableLanguages` write.
|
|
|
|
#### 2. Make `contentHash` an optional catalog-schema field
|
|
|
|
**File**: `10x-toolkit/packages/course-content/src/schemas/catalog.ts` (`catalogLessonSchema`, ~10-18)
|
|
|
|
**Intent**: Allow the field on a catalog lesson. It must be **optional**, because
|
|
`build:lessons` (`core.ts:buildCatalog`) emits the catalog *before* transform runs and
|
|
cannot populate it; the transform step fills it in.
|
|
|
|
**Contract**: Add `contentHash: z.string().optional()` to `catalogLessonSchema`. Add a
|
|
post-transform assertion (in `validate:bundles` or the transform script) that every
|
|
*published* lesson has a non-empty `contentHash`, so a missing digest fails CI rather
|
|
than silently degrading the CLI to always-fetch in production.
|
|
|
|
#### 3. Surface the field through the delivery API
|
|
|
|
**File**: `10x-toolkit/packages/api/src/routes/catalog.ts` (`enrichedCatalogSchema`, ~14-27)
|
|
|
|
**Intent**: Allow the field to pass through the API's response schema. The handler
|
|
already spreads `catalog.lessons` raw (`catalog.ts:79`), so no handler logic changes —
|
|
only the schema needs widening.
|
|
|
|
**Contract**: Add `contentHash: z.string()` to the lesson object in
|
|
`enrichedCatalogSchema`. Confirm it appears in the generated `/openapi.json`.
|
|
|
|
### Success Criteria:
|
|
|
|
#### Automated Verification:
|
|
- Digest aggregation is unit-tested over a fixture bundle: deterministic for fixed input, changes when any artifact hash changes, stable under artifact reordering (sorted), configs ignored
|
|
- `catalogLessonSchema` accepts a lesson with and without `contentHash` (optional): `pnpm --filter @przeprogramowani/course-content test`
|
|
- API tests pass (catalog route surfaces `contentHash` when present, passes schema): `pnpm --filter @przeprogramowani/api test`
|
|
- `build:lessons` still succeeds emitting catalog **without** `contentHash` (pre-transform): assert build is green and schema-valid
|
|
- Typecheck + lint pass across both packages
|
|
|
|
#### Manual Verification:
|
|
- After a real transform run, every lesson in `dist/10xdevs3/catalog.json` has a non-empty `contentHash`; re-running transform with no content change leaves each `contentHash` identical (deterministic)
|
|
- Edit one skill's `SKILL.md` + re-transform → only that lesson's `contentHash` changes
|
|
- Bump `SYSTEM_PROMPT_VERSION` → every lesson's `contentHash` changes (proves version-bump is captured, no silent skip)
|
|
- `GET /api/catalog/10xdevs3` (local API, R2 seeded with transformed output) returns `contentHash` on every lesson
|
|
|
|
**Implementation Note**: After automated verification passes, pause for human
|
|
confirmation that the digest is deterministic and changes only on real content edits
|
|
before proceeding. Deploy the API so `/openapi.json` carries the field (prerequisite for
|
|
Phase 2's type regeneration).
|
|
|
|
---
|
|
|
|
## Phase 2: CLI — `planBundle()` writer refactor + types (`10x-cli`)
|
|
|
|
### Overview
|
|
|
|
Extract a pure, non-writing, non-prompting planner so `sync` can classify and preview
|
|
changes, and wire the new catalog field into the CLI's types.
|
|
|
|
### Changes Required:
|
|
|
|
#### 1. Export a pure planner from the writer
|
|
|
|
**File**: `10x-cli/src/lib/writer.ts` (around `computeFileAction`, 445-464)
|
|
|
|
**Intent**: Provide `planBundle(bundle, projectRoot, { profile }): WritePlan` returning
|
|
per-file `{ path, action, isConflict, upstreamChanged }` **without** invoking
|
|
`onConflict` and **without** any filesystem mutation. Refactor `applyBundle` to consume
|
|
the same planner so classification and application can't diverge.
|
|
|
|
**Contract**: New exported `planBundle()` + a `WritePlan` type listing per-file entries
|
|
with the existing `ArtifactAction` union plus `isConflict: boolean` and
|
|
`upstreamChanged: boolean`. `computeFileAction`'s three-way logic moves into / is reused
|
|
by the planner. `applyBundle`'s observable behavior (actions, removals, conflict
|
|
handling) is unchanged.
|
|
|
|
#### 2. Add optional `contentHash` to `LessonSummary`
|
|
|
|
**File**: `10x-cli/src/lib/api-content.ts` (`LessonSummary`, 27-35)
|
|
|
|
**Intent**: Carry the new catalog field so `sync` can read it; optional so the CLI still
|
|
works against a backend that doesn't yet publish it.
|
|
|
|
**Contract**: Add `contentHash?: string` to `LessonSummary`.
|
|
|
|
#### 3. Regenerate the API types from the deployed OpenAPI
|
|
|
|
**File**: `10x-cli/src/generated/api-types.ts` (generated — do not hand-edit)
|
|
|
|
**Intent**: Pick up the new catalog field from the deployed spec.
|
|
|
|
**Contract**: Run `bun run generate-types` (against the deployed API, or
|
|
`API_BASE_URL=…` for a local one). The regenerated catalog response type includes
|
|
`contentHash` on lesson summaries. **Prerequisite: Phase 1 deployed.**
|
|
|
|
### Success Criteria:
|
|
|
|
#### Automated Verification:
|
|
- Typecheck passes: `bun run typecheck`
|
|
- Lint passes: `bun run lint`
|
|
- Writer tests pass, including new `planBundle` parity tests: `bun test tests/writer*.test.ts`
|
|
- Regenerated `api-types.ts` contains `contentHash` on the catalog lesson type
|
|
|
|
#### Manual Verification:
|
|
- `planBundle` output matches `applyBundle`'s resulting actions for a representative bundle (clean update, unchanged, user-edit conflict, new file)
|
|
- A TTY `--dry-run` preview built on `planBundle` does NOT prompt
|
|
|
|
**Implementation Note**: Pause for human confirmation that `planBundle`/`applyBundle`
|
|
parity holds before building the command on top of it.
|
|
|
|
---
|
|
|
|
## Phase 3: CLI — the `10x sync` command (`10x-cli`)
|
|
|
|
### Overview
|
|
|
|
The user-facing command: enumerate, cheap-skip unchanged, fetch-and-apply the rest,
|
|
emit one actionable report, exit by worst outcome.
|
|
|
|
### Changes Required:
|
|
|
|
#### 1. New command module
|
|
|
|
**File**: `10x-cli/src/commands/sync.ts` (new) + registration in `10x-cli/src/index.ts`
|
|
|
|
**Intent**: Add `registerSyncCommand(cli)` following the existing command pattern and
|
|
wire it into the shared CAC instance.
|
|
|
|
**Contract**: Command `10x sync` with flags `--all`, `--dry-run`, `--force`, `--module <mN>`,
|
|
`--course <slug>`, `--tool <tool>`, `--lang <lang>`, `--no-course-rules` / `--course-rules`,
|
|
`--json`, `--verbose`. Action callback goes through `resolveContext` / `outputError` per
|
|
the I/O contract; unknown-option → exit 2 preserved.
|
|
|
|
#### 2. Orchestration algorithm
|
|
|
|
**File**: `10x-cli/src/commands/sync.ts`
|
|
|
|
**Intent**: Drive the sweep using existing primitives without re-implementing fetch,
|
|
apply, or auth.
|
|
|
|
**Contract**:
|
|
- `requireAuth(ctx)` **once**; `resolveContext(options)`; `resolveToolProfile`; `readManifest`.
|
|
- `fetchCatalog` → unlocked lessons. Target set: **default** = unlocked ∩
|
|
`Object.keys(manifest.lessons)`; **`--all`** = all unlocked; `--module mN` filters.
|
|
Record locked/excluded lessons + reason from `modules[].effectiveState`.
|
|
- Per lesson, in module/lesson order, **sequentially or via a small bounded pool**
|
|
sharing one `AbortSignal`:
|
|
- **Cheap-skip**: if NOT `--force` AND `lesson.contentHash` present AND equals the
|
|
manifest's stored per-lesson catalog digest → mark `unchanged`, do **not** fetch.
|
|
`--force` bypasses this gate entirely (always fetch) so it can overwrite local edits
|
|
even when upstream is unchanged. Absent `lesson.contentHash` or absent stored digest
|
|
→ fetch (fallback).
|
|
- Else `fetchLesson` (keeps signature verification) → `planBundle` to classify.
|
|
- If `--dry-run`: record the plan, write nothing.
|
|
- Else `applyBundle` with a conflict resolver chosen by `--force`: default resolver =
|
|
skip (report), `--force` resolver = overwrite. On apply, store the catalog's
|
|
`lesson.contentHash` into the manifest for that lesson.
|
|
- Accumulate per-lesson outcomes; **never `process.exit` mid-loop**.
|
|
|
|
#### 3. Manifest: store the per-lesson catalog digest
|
|
|
|
**File**: `10x-cli/src/lib/manifest.ts` (`LessonFilesEntry`, 31-36, + read/write paths)
|
|
|
|
**Intent**: Persist the catalog digest that was current when a lesson was applied, so the
|
|
next sync can compare digest-vs-digest.
|
|
|
|
**Contract**: Add `catalogContentHash?: string` to `LessonFilesEntry`, written on apply,
|
|
tolerated as absent on read (older manifests → always-fetch fallback). Additive +
|
|
optional; keep manifest read backward-compatible (no forced version bump required —
|
|
follow the existing v3 tolerance pattern).
|
|
|
|
#### 4. Actionable aggregate report
|
|
|
|
**File**: `10x-cli/src/commands/sync.ts`
|
|
|
|
**Intent**: One envelope summarizing the sweep, where every resource that was **not**
|
|
updated tells the user the exact command to get it.
|
|
|
|
**Contract**: Human table + JSON `{ lessons: [...], totals: {...} }`. Classify per-file
|
|
actions into: `upstream-updated`, `created`, `unchanged`, `skipped-conflict`,
|
|
`removed`, plus per-lesson `errored`. For each **skipped-conflict** resource, include a
|
|
remediation command: targeted `10x get <lessonId> --type <type> --name <name>` to take
|
|
that one update, and a one-line global hint `10x sync --force` to take all upstream
|
|
updates. For each **errored** lesson, show the error + a retry command
|
|
(`10x get <lessonId>` or `10x sync --module <mN>`). Exit code = **worst outcome**: `0`
|
|
if all clean/unchanged; `1` (ERROR) if any lesson errored; conflicts-skipped alone stays
|
|
`0` (they're reported, not failures).
|
|
|
|
### Success Criteria:
|
|
|
|
#### Automated Verification:
|
|
- Typecheck + lint pass: `bun run typecheck && bun run lint`
|
|
- `10x sync --all` downloads all unlocked lessons (mocked catalog/bundles): `bun test tests/sync*.test.ts`
|
|
- Default `sync` targets only `manifest.lessons`; `--module` filters correctly
|
|
- Cheap-skip: a lesson whose catalog `contentHash` matches the stored digest is NOT fetched
|
|
- Changed lesson (digest differs) is fetched and applied
|
|
- `--force` bypasses the gate: a lesson whose digest matches IS still fetched and overwritten
|
|
- `--dry-run` writes nothing (assert no fs mutations) and still reports the plan
|
|
- Conflict default → `skipped-conflict` with a remediation command string in the report; `--force` → overwrite
|
|
- Partial failure (one lesson errors) → exit code `1`, full report still emitted
|
|
- Locked-module lessons excluded with a reason
|
|
|
|
#### Manual Verification:
|
|
- Real `10x sync --all` against a local API populates a fresh project in correct order
|
|
- Re-running `10x sync` after no upstream change reports all unchanged and downloads nothing
|
|
- After editing a downloaded skill, `sync` reports it skipped with a copy-pasteable `10x get …` command that then takes the update
|
|
- `--json` output is well-formed and stdout-only; human report is stderr-only
|
|
|
|
**Implementation Note**: Pause for human confirmation of the end-to-end UX (report
|
|
wording, remediation commands, exit codes) before finalizing docs.
|
|
|
|
---
|
|
|
|
## Phase 4: Tests & docs
|
|
|
|
### Overview
|
|
|
|
Lock in coverage and document the new command across both repos' contributor + user docs.
|
|
|
|
### Changes Required:
|
|
|
|
#### 1. Sync command test suite
|
|
|
|
**File**: `10x-cli/tests/sync-command.test.ts` (new) + reuse `tests/helpers/api-content-mock.ts`
|
|
|
|
**Intent**: Cover the matrix: full `--all` download, update-applied default, digest
|
|
cheap-skip (no fetch), changed-lesson fetch+apply, conflict skip+report+remediation,
|
|
`--force` overwrite, `--dry-run` no-write, partial-failure exit code, locked-module
|
|
exclusion, manifest digest round-trip.
|
|
|
|
**Contract**: `bun:test` with `mock.module()` / DI per repo convention; assert on the
|
|
JSON envelope and exit codes, not human strings where avoidable.
|
|
|
|
#### 2. User + contributor docs
|
|
|
|
**File**: `10x-cli/README.md`, `10x-cli/CHANGELOG.md`, `10x-cli/CLAUDE.md`, `10x-cli` AGENTS notes; `10x-toolkit/packages/api/contracts/delivery-api.md`
|
|
|
|
**Intent**: Document `10x sync` (flags, default vs `--all`, `--dry-run`, `--force`, the
|
|
report + remediation commands, exit-code semantics, the skills/prompts-only change-
|
|
visibility limitation) and the new catalog `contentHash` contract.
|
|
|
|
**Contract**: README "sync" section; CHANGELOG entry; CLAUDE.md sync notes; delivery-api
|
|
contract documents the catalog `contentHash` field. Update the `10x-cli-guide` skill if
|
|
it enumerates commands.
|
|
|
|
### Success Criteria:
|
|
|
|
#### Automated Verification:
|
|
- Full suite passes in both repos: `bun test` (10x-cli) and `pnpm test` (10x-toolkit api + course-content)
|
|
- Build + binary build pass: `bun run build && bun run build:binary`
|
|
- Lint + typecheck pass in both repos
|
|
|
|
#### Manual Verification:
|
|
- README accurately reflects observed `10x sync` behavior (flags, report, exit codes)
|
|
- delivery-api contract matches the deployed catalog response
|
|
|
|
**Implementation Note**: Final phase — confirm docs match real behavior before closing.
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests:
|
|
- `planBundle` parity with `applyBundle` across clean-update / unchanged / conflict / new-file
|
|
- Digest comparison logic: skip when equal, fetch when differ, fetch when catalog hash absent
|
|
- Report classification: per-file action → bucket; remediation-command construction
|
|
- Exit-code selection from mixed per-lesson outcomes
|
|
|
|
### Integration Tests:
|
|
- Mocked catalog + bundles: `--all` populates correct files in order; cheap-skip avoids fetch (assert fetch not called); `--dry-run` writes nothing; `--force` overwrites a conflicted file
|
|
- Backend: catalog route returns `contentHash`; build emits deterministic per-lesson digest
|
|
|
|
### Manual Testing Steps:
|
|
1. Local API + fresh project: `10x sync --all` → all unlocked lessons land in order.
|
|
2. Re-run `10x sync` → everything reported unchanged, zero downloads.
|
|
3. Edit a downloaded skill, run `10x sync` → reported `skipped-conflict` with a
|
|
`10x get … --type skills --name …` command; run it → update taken.
|
|
4. `10x sync --force` → conflicted files overwritten with upstream.
|
|
5. `10x sync --dry-run` → report only, no writes.
|
|
6. Simulate one lesson 404 → exit code 1, full report still shown.
|
|
|
|
## Performance Considerations
|
|
|
|
A sync downloads only changed lessons once the catalog `contentHash` is live, collapsing
|
|
the common "nothing changed" case to a single catalog GET. Until then (older backend),
|
|
it re-downloads every targeted lesson. Keep the loop sequential or a small bounded pool
|
|
with a shared `AbortSignal`; respect the existing 30s per-call timeout and surface `429`
|
|
as today (no retry framework added).
|
|
|
|
## Migration Notes
|
|
|
|
`LessonFilesEntry.catalogContentHash` is additive + optional; existing manifests read
|
|
fine and simply always-fetch until the next apply records a digest. No data migration.
|
|
|
|
## References
|
|
|
|
- Research (CLI + backend): `context/changes/bulk-sync-update/research.md`
|
|
- Writer three-way detection: `10x-cli/src/lib/writer.ts:445-464`
|
|
- Catalog handler (spreads lessons raw): `10x-toolkit/packages/api/src/routes/catalog.ts:79`
|
|
- Build-time artifact hash: `10x-toolkit/packages/course-content/scripts/transform-content.mjs:100-101`
|
|
- Master plan non-goal (now reversed): `10x-toolkit/context/archive/2026-04-07-10x-cli-design/plan.md:63`
|
|
|
|
## Progress
|
|
|
|
> Convention: `- [ ]` pending, `- [x]` done. Append ` — <commit sha>` when a step lands. Do not rename step titles. See `references/progress-format.md`.
|
|
|
|
### Phase 1: Backend — per-lesson contentHash on the catalog
|
|
|
|
#### Automated
|
|
- [x] 1.1 Course-content build emits a contentHash per lesson (assert in dist/10xdevs3/catalog.json) — 52446dd
|
|
- [x] 1.2 Course-content tests pass — 52446dd
|
|
- [x] 1.3 API tests pass (catalog route asserts contentHash present + schema-valid) — 52446dd
|
|
- [x] 1.4 Typecheck + lint pass across both packages — 52446dd
|
|
|
|
#### Manual
|
|
- [ ] 1.5 Build twice, no content change → identical per-lesson contentHash (deterministic)
|
|
- [ ] 1.6 Edit one skill → only that lesson's contentHash changes
|
|
- [ ] 1.7 GET /api/catalog/10xdevs3 returns contentHash on every lesson
|
|
|
|
### Phase 2: CLI — planBundle() writer refactor + types
|
|
|
|
#### Automated
|
|
- [x] 2.1 Typecheck passes — ca4d0bf
|
|
- [x] 2.2 Lint passes — ca4d0bf
|
|
- [x] 2.3 Writer tests pass, including planBundle parity tests — ca4d0bf
|
|
- [x] 2.4 Regenerated api-types.ts contains contentHash on the catalog lesson type — ca4d0bf
|
|
|
|
#### Manual
|
|
- [ ] 2.5 planBundle output matches applyBundle actions across clean/unchanged/conflict/new
|
|
- [ ] 2.6 TTY --dry-run preview built on planBundle does NOT prompt
|
|
|
|
### Phase 3: CLI — the 10x sync command
|
|
|
|
#### Automated
|
|
- [x] 3.1 Typecheck + lint pass — ec8a2a6
|
|
- [x] 3.2 `10x sync --all` downloads all unlocked lessons — ec8a2a6
|
|
- [x] 3.3 Default sync targets only manifest.lessons; --module filters — ec8a2a6
|
|
- [x] 3.4 Cheap-skip: matching catalog contentHash → lesson NOT fetched — ec8a2a6
|
|
- [x] 3.5 Changed lesson (digest differs) is fetched and applied — ec8a2a6
|
|
- [x] 3.6 --dry-run writes nothing and still reports the plan — ec8a2a6
|
|
- [x] 3.7 Conflict default → skipped-conflict with remediation command; --force → overwrite — ec8a2a6
|
|
- [x] 3.8 Partial failure → exit code 1, full report still emitted — ec8a2a6
|
|
- [x] 3.9 Locked-module lessons excluded with a reason — ec8a2a6
|
|
|
|
#### Manual
|
|
- [ ] 3.10 Real sync --all against local API populates project in correct order
|
|
- [ ] 3.11 Re-run after no change → all unchanged, zero downloads
|
|
- [ ] 3.12 Edited skill → reported skipped with copy-pasteable get command that takes the update
|
|
- [ ] 3.13 --json is stdout-only and well-formed; human report stderr-only
|
|
|
|
### Phase 4: Tests & docs
|
|
|
|
#### Automated
|
|
- [x] 4.1 Full suite passes in both repos (bun test / pnpm test) — 26d70d5
|
|
- [x] 4.2 Build + binary build pass — 26d70d5
|
|
- [x] 4.3 Lint + typecheck pass in both repos — 26d70d5
|
|
|
|
#### Manual
|
|
- [ ] 4.4 README reflects observed sync behavior (flags, report, exit codes)
|
|
- [ ] 4.5 delivery-api contract matches deployed catalog response
|