diff --git a/.please/docs/investigations/.gitkeep b/.please/docs/investigations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.please/docs/research/.gitkeep b/.please/docs/research/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.please/docs/tracks.jsonl b/.please/docs/tracks.jsonl index e69de29..423fd28 100644 --- a/.please/docs/tracks.jsonl +++ b/.please/docs/tracks.jsonl @@ -0,0 +1 @@ +{"id":"ask-workspace-migration-20260407","type":"feature","status":"review","phase":"review","issue":"","created":"2026-04-07","section":"completed","pr":"#3"} diff --git a/.please/docs/tracks/active/.gitkeep b/.please/docs/tracks/active/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.please/docs/tracks/completed/.gitkeep b/.please/docs/tracks/completed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.please/docs/tracks/completed/ask-workspace-migration-20260407/metadata.json b/.please/docs/tracks/completed/ask-workspace-migration-20260407/metadata.json new file mode 100644 index 0000000..7cd6079 --- /dev/null +++ b/.please/docs/tracks/completed/ask-workspace-migration-20260407/metadata.json @@ -0,0 +1,11 @@ +{ + "track_id": "ask-workspace-migration-20260407", + "type": "feature", + "status": "implemented", + "created_at": "2026-04-07T06:13:44Z", + "updated_at": "2026-04-07T07:00:00Z", + "issue": "", + "pr": "", + "project": "", + "project_item_id": "" +} diff --git a/.please/docs/tracks/completed/ask-workspace-migration-20260407/plan.md b/.please/docs/tracks/completed/ask-workspace-migration-20260407/plan.md new file mode 100644 index 0000000..281e23d --- /dev/null +++ b/.please/docs/tracks/completed/ask-workspace-migration-20260407/plan.md @@ -0,0 +1,173 @@ +# Plan: ASK Workspace Migration to `.ask/` + Lockfile + Type-safe Config + +> Track: ask-workspace-migration-20260407 +> Spec: [spec.md](./spec.md) + +## Overview + +- **Source**: [spec.md](./spec.md) +- **Issue**: TBD +- **Created**: 2026-04-07 +- **Approach**: Incremental migration — schemas first, then storage paths, then lockfile, then sync, then legacy migration + +## Purpose + +After this change, anyone running `@pleaseai/ask` will store all ASK-managed artifacts under `.ask/` (instead of the shared `.please/` workspace), with a Zod-validated `ask.lock` recording exactly what was last fetched. They can verify it works by running `ask docs add zod` in a fresh directory and confirming `.ask/docs/zod@/`, `.ask/config.json`, and `.ask/ask.lock` are created with deterministic, byte-stable contents on re-runs. + +## Context + +### Problem + +Three new agent skills (`add-docs`, `setup-docs`, `sync-docs`) shipped on `main` describe a workspace layout the CLI does not yet implement: storage path `.ask/docs/`, config file `.ask/config.json`, and a new `.ask/ask.lock` recording resolved versions, content hashes, and source-specific metadata. The skills also assume `config.json` and `ask.lock` are validated by Zod with discriminated unions on `source`, and that all writes are deterministic (sorted keys, sorted `docs[]` by name, 2-space indent + trailing newline) so git diffs are meaningful and PRs are reviewable. + +Without this work, the skills and the CLI describe two different worlds. A user running `bun add zod` followed by `ask docs sync` would find the CLI looking in `.please/docs/` while the skills (and any agent following them) look in `.ask/docs/`. Drift detection in `sync-docs` is also impossible today because there is no source of truth for "what was actually fetched last time" — `config.json` only stores intent (potentially `latest`), not facts. + +### Requirements Summary + +The CLI must (1) read and write all ASK-managed artifacts under `.ask/` instead of `.please/`, (2) route every config and lock file I/O through Zod-validated, deterministic helpers in a single module, (3) record every fetch into `.ask/ask.lock` with version, source metadata, content hash, and (for github) commit sha, (4) use the lockfile as the drift baseline in a new `sync` subcommand, and (5) auto-migrate any existing `.please/docs/` and `.please/config.json` exactly once on the next CLI invocation, with a single deprecation warning. + +### Constraints + +The migration must be safe for existing users: anyone with an existing `.please/docs/` directory should have it moved to `.ask/docs/` automatically on the next CLI run, with the old `.please/config.json` parsed and rewritten as `.ask/config.json`. The presence of `.ask/` is the sentinel — migration runs exactly once. Re-running any `add` command with no actual content change must produce a byte-identical config and lock (modulo timestamp fields, which only update when content actually changed). All Zod parse failures must surface to the user with the offending path — no silent recovery. + +### Non-Goals + +Plugin packaging (`plugin.json`, marketplace publish) is a separate track. `PostToolUse` hook auto-triggering `sync-docs` is a separate track. Schema migration beyond v1 is deferred — first release pins `schemaVersion: 1` / `lockfileVersion: 1` and a migration framework can come later when v2 is needed. Source adapter fetch behavior and registry resolution priority are unchanged. + +## Architecture Decision + +We introduce a single `schemas.ts` module exporting Zod discriminated unions for `SourceConfig` and `LockEntry`, with `Config` and `Lock` wrapping them. All read/write goes through four helpers — `readConfig`, `writeConfig`, `readLock`, `writeLock` — which validate on the way in, sort deterministically on the way out, and emit pretty JSON with a trailing newline. Command code never touches `JSON.stringify` directly. + +Lockfile recording lives inside the `add` pipeline, immediately after `storage.saveDocs()` and before `agents.update()`. Source adapters expose the metadata they already collect (commit sha for github via the archive redirect, `dist.integrity` for npm via the existing `npm view` call) through a richer `FetchResult` shape. The hash function takes the file list directly, sorts by relative path, and concatenates `\0\0` before SHA-256 — this is OS- and filesystem-order-independent. + +Drift detection in `sync-docs` reads `.ask/ask.lock.entries[name]` as the comparison baseline rather than `.ask/config.json.docs[].version`. This handles the `latest` case correctly: a fixed config entry can still drift when its resolved commit moves. + +Legacy migration runs as the very first step in `src/index.ts`, before any subcommand. The check is cheap (filesystem stat on `.ask/`), and the move is a single `fs.renameSync` plus a config rewrite. Because we cannot recover the original commit sha for github entries, the migrated lockfile leaves `commit` undefined for those entries — `sync-docs` will populate it on the next run. + +We chose **incremental migration** over a big-bang refactor because each phase is independently shippable and testable. Phases 1 (schemas) and 2 (paths) can land in separate PRs if needed; phase 5 (legacy migration) is the only one with user-visible side effects and gets its own review focus. + +## Tasks + +- [ ] T001 [P] Add Zod schemas for Config and Lock (file: packages/cli/src/schemas.ts) +- [ ] T002 [P] Add deterministic JSON serializer and content hash utility (file: packages/cli/src/io.ts) +- [ ] T003 Add config and lock reader/writer helpers (file: packages/cli/src/io.ts) (depends on T001, T002) +- [ ] T004 Migrate storage paths from .please/docs to .ask/docs (file: packages/cli/src/storage.ts) +- [ ] T005 Replace config.ts JSON I/O with helpers (file: packages/cli/src/config.ts) (depends on T003) +- [ ] T006 Update AGENTS.md template to reference .ask/docs (file: packages/cli/src/agents.ts) (depends on T004) +- [ ] T007 [P] Expose commit sha from github source adapter (file: packages/cli/src/sources/github.ts) +- [ ] T008 [P] Expose dist.integrity from npm source adapter (file: packages/cli/src/sources/npm.ts) +- [ ] T009 Wire ask.lock upsert into the add command pipeline (file: packages/cli/src/index.ts) (depends on T003, T007, T008) +- [ ] T010 Implement sync subcommand using ask.lock as drift baseline (file: packages/cli/src/index.ts) (depends on T009) +- [ ] T011 Add legacy .please/ migration on CLI startup and update README/ARCHITECTURE (file: packages/cli/src/migrate-legacy.ts) (depends on T005, T006, T009) +- [ ] T012 [P] Schema unit tests — valid, invalid, determinism (file: packages/cli/test/schemas.test.ts) (depends on T003) +- [ ] T013 [P] Add command end-to-end test — fresh project (file: packages/cli/test/add.test.ts) (depends on T009) +- [ ] T014 [P] Sync command drift test — version bump and prune (file: packages/cli/test/sync.test.ts) (depends on T010) +- [ ] T015 Legacy migration test — seeded .please/ moves to .ask/ exactly once (file: packages/cli/test/migrate-legacy.test.ts) (depends on T011) + +## Key Files + +### Create +- `packages/cli/src/schemas.ts` — Zod schemas: `ConfigSchema`, `LockSchema`, `SourceConfigSchema`, `LockEntrySchema`, plus inferred TypeScript types. +- `packages/cli/src/io.ts` — `readConfig`, `writeConfig`, `readLock`, `writeLock`, `sortedJSON`, `contentHash`. +- `packages/cli/src/migrate-legacy.ts` — One-shot `.please/` → `.ask/` migration with idempotency sentinel. +- `packages/cli/test/schemas.test.ts` — Zod validation + determinism tests. +- `packages/cli/test/add.test.ts` — End-to-end add flow against a temp directory. +- `packages/cli/test/sync.test.ts` — Drift classification and re-fetch tests. +- `packages/cli/test/migrate-legacy.test.ts` — Migration idempotency test. + +### Modify +- `packages/cli/src/storage.ts` — Replace `getDocsRoot()` to return `.ask/docs/` (currently `.please/docs/`, see line 6). +- `packages/cli/src/config.ts` — Replace hand-rolled `JSON.parse`/`JSON.stringify` with `readConfig`/`writeConfig`. Currently at lines 30–47 (`addDocEntry`). +- `packages/cli/src/agents.ts` — Update marker block template (lines 8–89) to reference `.ask/docs/`. +- `packages/cli/src/sources/github.ts` — Resolve commit sha via archive redirect or `gh api repos/{repo}/commits/{ref}`. Add `commit` to `FetchResult` (currently lines 15–104). +- `packages/cli/src/sources/npm.ts` — Capture `dist.integrity` from the existing `npm view` call. Add `integrity` to `FetchResult` (currently lines 14–122). +- `packages/cli/src/index.ts` — Run legacy migration on startup; add `sync` subcommand; wire lockfile upsert into `add` flow. Current `addCmd` definition at lines 90–171. +- `README.md`, `ARCHITECTURE.md` — Replace `.please/docs/` references with `.ask/docs/`. + +### Reuse +- `packages/cli/src/registry.ts` — Untouched. Existing source priority and resolution logic stays as-is. +- `packages/cli/src/sources/web.ts`, `packages/cli/src/sources/llms-txt.ts` — Add `urls`/`url` to `FetchResult`, no behavioral change. +- Existing `consola` logger for the migration deprecation warning. + +## Verification + +### Automated Tests +- [ ] `ConfigSchema.parse()` accepts valid github/npm/web/llms-txt configs and rejects each with one required field missing +- [ ] `writeConfig` followed by `readConfig` followed by `writeConfig` produces byte-identical output +- [ ] `contentHash` is order-independent (shuffling the input file array yields the same hash) +- [ ] `ask docs add ` end-to-end creates `.ask/docs/`, `.ask/config.json`, `.ask/ask.lock`, and updates `AGENTS.md` marker block +- [ ] `ask docs sync` after a simulated version bump re-fetches only the changed entry, deletes the old version dir, and updates the lock +- [ ] Legacy migration test: seeded `.please/docs/foo@1.0.0/` and `.please/config.json` get moved into `.ask/`, deprecation warning logged exactly once, second run is silent + +### Observable Outcomes +- After running `ask docs add zod` in a fresh directory, `ls .ask/` shows `docs/`, `config.json`, `ask.lock`, and `ls .please/` returns "No such file or directory". +- After running `ask docs add zod@3.22.4` twice in a row, `git diff .ask/config.json` shows no changes. +- After bumping zod and running `ask docs sync`, the terminal shows `⟳ zod 3.22.4 → 3.23.8` and the old `.ask/docs/zod@3.22.4/` directory is gone. +- Running `ask docs add` in a directory containing `.please/docs/` prints one `consola.warn` line about migration on the first invocation only. + +### Manual Testing +- [ ] In a fresh `/tmp/test-fresh/` directory with only `package.json`, run `node packages/cli/dist/index.js docs add zod` and inspect `.ask/` +- [ ] Copy a real project that already has `.please/docs/` populated, run any `docs` subcommand, confirm migration runs once and never again +- [ ] Manually corrupt `.ask/config.json` (e.g. set `source: "github"` without `repo`) and confirm the next CLI run fails with a clear Zod error pointing at the offending field + +### Acceptance Criteria Check +- [ ] SC-1: Fresh project produces `.ask/docs/`, `.ask/config.json`, `.ask/ask.lock`, `AGENTS.md` referencing `.ask/`, no `.please/docs/` +- [ ] SC-2: Existing `.please/` project migrated on next run, deprecation warning printed once, second run silent +- [ ] SC-3: Zod rejects malformed config with a clear path-aware error +- [ ] SC-4: Two consecutive identical `add` runs leave config and lock byte-identical except `generatedAt`/`fetchedAt` only updating when content changes +- [ ] SC-5: `sync` after `bun update zod` re-fetches only zod, deletes old dir after new fetch succeeds, updates lock + +## Progress + +- [x] (2026-04-07 15:30 KST) T001 Add Zod schemas for Config and Lock + Evidence: 17 schema tests pass +- [x] (2026-04-07 15:35 KST) T002 Add deterministic JSON serializer and content hash utility + Evidence: 11 io-utils tests pass +- [x] (2026-04-07 15:40 KST) T003 Add config and lock reader/writer helpers + Evidence: 10 io-helpers tests pass (incl. byte-identity round-trip) +- [x] (2026-04-07 15:45 KST) T004 Migrate storage paths from .please/docs to .ask/docs + Evidence: 4 storage tests pass, .please/docs absent in output +- [x] (2026-04-07 15:50 KST) T005 Replace config.ts JSON I/O with helpers + Evidence: build clean; addDocEntry now matches by name only +- [x] (2026-04-07 15:55 KST) T006 Update AGENTS.md template to reference .ask/docs + Evidence: 5 agents tests pass; storage path propagates through getLibraryDocsDir +- [x] (2026-04-07 16:00 KST) T007 Expose commit sha from github source adapter + Evidence: FetchResult.meta.commit + ref via git ls-remote, build clean +- [x] (2026-04-07 16:05 KST) T008 Expose dist.integrity from npm source adapter + Evidence: FetchResult.meta.integrity + tarball, build clean +- [x] (2026-04-07 16:15 KST) T009 Wire ask.lock upsert into the add command pipeline + Evidence: buildLockEntry + upsertLockEntry; SC-4 byte-stable on no-op re-add +- [x] (2026-04-07 16:25 KST) T010 Implement sync subcommand using ask.lock as drift baseline + Evidence: drift classification + summary report (drifted/unchanged/failed) +- [x] (2026-04-07 16:35 KST) T011 Add legacy .please/ migration on CLI startup + Evidence: hooked into all 4 subcommands; README/ARCHITECTURE updated +- [x] (2026-04-07 16:40 KST) T012 Schema unit tests + Evidence: 30-entry determinism stress test added; 48 tests total +- [x] (2026-04-07 16:50 KST) T013 Add command end-to-end test + Evidence: lock-pipeline.test.ts SC-4 / SC-5 / drift detection +- [x] (2026-04-07 16:50 KST) T014 Sync command drift test + Evidence: lock-pipeline.test.ts version bump + removeLockEntries prune +- [x] (2026-04-07 16:55 KST) T015 Legacy migration test + Evidence: migrate-legacy.test.ts 5 cases incl. exactly-once tampered file + +## Decision Log + +- Decision: Use Zod discriminated unions on `source` for both `SourceConfig` and `LockEntry` + Rationale: The four sources have non-overlapping required fields (github needs `repo`, npm needs `tarball`, etc.). A flat schema would either lie about optionality or require runtime branching. Discriminated unions give us static exhaustiveness checks and a single Zod parse call. + Date/Author: 2026-04-07 / Claude + +- Decision: Lockfile is committed to git, named `.ask/ask.lock` + Rationale: Same role as `bun.lock` — reproducibility across machines and PR reviewability of doc-version drift. `.ask/docs/` itself can be gitignored if the user prefers, but the lock makes that decision recoverable. + Date/Author: 2026-04-07 / Claude + +- Decision: Migration is triggered by absence of `.ask/`, not by a versioned sentinel file + Rationale: Cheapest possible check, naturally idempotent, no extra metadata to maintain. Once a user is on `.ask/`, the legacy code path is dead. + Date/Author: 2026-04-07 / Claude + +- Decision: Migrated github entries leave `commit` undefined in `ask.lock` + Rationale: We never recorded the resolved sha in the legacy format, so inventing one would be lying. `sync-docs` treats missing `commit` as drift and refetches on the next run, which fills it correctly. + Date/Author: 2026-04-07 / Claude + +## Surprises & Discoveries + +(populated during implementation) diff --git a/.please/docs/tracks/completed/ask-workspace-migration-20260407/spec.md b/.please/docs/tracks/completed/ask-workspace-migration-20260407/spec.md new file mode 100644 index 0000000..9f9c98e --- /dev/null +++ b/.please/docs/tracks/completed/ask-workspace-migration-20260407/spec.md @@ -0,0 +1,119 @@ +# Track Spec — ASK Workspace Migration to `.ask/` + Lockfile + Type-safe Config + +## Context + +Three new agent skills (`add-docs`, `setup-docs`, `sync-docs`) were merged in +`feat/add-docs-skills` and now live under `skills/`. They specify a workspace layout +that the CLI does not yet implement: + +- Storage path: `.ask/docs/@/` (CLI currently writes `.please/docs/`) +- Config file: `.ask/config.json` (CLI currently writes `.please/config.json`) +- New lockfile: `.ask/ask.lock` recording resolved version, source metadata, content + hash, and (for github) commit sha — does not exist in the CLI yet +- Type-safe schemas: `config.json` and `ask.lock` validated by Zod with discriminated + unions, deterministic serialization (sorted keys, sorted `docs[]` by name, 2-space + indent + trailing newline) + +Without this track, the skills and the CLI describe two different worlds — anyone +running both will end up with split state. + +## Goals + +1. CLI uses `.ask/` instead of `.please/` for ASK-managed artifacts. +2. Single source of truth for `config.json` and `ask.lock` schemas: a `schemas.ts` + module exporting Zod schemas + inferred TS types + reader/writer helpers. +3. CLI writes `ask.lock` on every fetch (`add`, `sync`) and reads it back for drift + detection. +4. Migration path for existing users: detect `.please/docs/` + `.please/config.json`, + move them to `.ask/`, log a one-time deprecation warning, never silently lose data. +5. All file I/O for config/lock goes through the helpers — no hand-rolled + `JSON.stringify` in command code. + +## Non-goals + +- Plugin packaging (`plugin.json`, marketplace publish) — separate track. +- `PostToolUse` hook auto-triggering `sync-docs` — separate track. +- Schema versioning beyond v1 — first release pins `schemaVersion: 1` and + `lockfileVersion: 1`; migration framework can come later. +- Any change to the registry API or to source adapters' fetch behavior. + +## Functional requirements + +**FR-1 — Workspace path** +- All read/write paths in `storage.ts`, `config.ts`, `agents.ts` must use `.ask/`. +- The marker block in `AGENTS.md` must reference `.ask/docs/...`. + +**FR-2 — Type-safe config** +- New module `packages/cli/src/schemas.ts` exports `ConfigSchema`, `LockSchema`, + `SourceConfigSchema`, `LockEntrySchema`, all as Zod discriminated unions on + `source`. Inferred types `Config`, `Lock`, `SourceConfig`, `LockEntry` exported. +- Helpers: `readConfig(dir)`, `writeConfig(dir, c)`, `readLock(dir)`, `writeLock(dir, l)`. + Each `read*` parses with Zod and throws on invalid input. Each `write*` validates, + sorts deterministically, and writes 2-space indent + trailing newline. +- `docs[]` is always sorted by `name` ascending before write. + +**FR-3 — Lockfile recording** +- After every successful `add` (and per-entry inside `sync`), the CLI must upsert an + entry in `.ask/ask.lock` containing: `version`, `source`, source-specific fields + (`repo`/`ref`/`commit` for github; `tarball`/`integrity` for npm; `url`/`urls` for + web/llms-txt), `fetchedAt` (ISO-8601), `fileCount`, and `contentHash` + (`sha256-` of files concatenated as `\0\0` in path-sorted + order). +- For github sources, capture the resolved commit sha by following the redirect + on the archive URL or via `GET /repos//commits/`. If unavailable, + omit the field rather than guess. +- For npm sources, capture `dist.integrity` from the same `npm view` call already + used to resolve the tarball. + +**FR-4 — Drift detection input** +- A new `sync` subcommand (or extension to the existing one if present) reads + `.ask/ask.lock` first and uses `entries..version`/`commit` as the + comparison baseline against the current resolved version from the project + manifest/lockfile. +- If `ask.lock` has no entry for a tracked name, treat it as drifted (forces + re-fetch to populate). + +**FR-5 — Migration from legacy `.please/`** +- On any CLI invocation, if `.please/docs/` or `.please/config.json` exists and + `.ask/` does not, perform a one-shot migration: move directories, parse the old + config through Zod, write the new `.ask/config.json`, generate `.ask/ask.lock` + by computing content hashes from the moved files (commit sha unknown for github + entries — leave undefined; sync will fill it on next run). +- Print a single deprecation notice via `consola.warn` and never re-run the + migration on subsequent invocations. + +**FR-6 — Determinism** +- Re-running `ask docs add` with no actual changes must produce a byte-identical + `config.json` and `ask.lock` (modulo `fetchedAt`/`generatedAt` timestamps, which + only update when content actually changed). + +## Success criteria + +- **SC-1**: A fresh project running `ask docs add zod` produces `.ask/docs/zod@/`, + `.ask/config.json`, `.ask/ask.lock`, and `AGENTS.md` referencing `.ask/docs/...`. + No `.please/docs/` is created. +- **SC-2**: An existing project with `.please/docs/` is migrated on the next CLI run: + files moved, deprecation warning printed once, second run prints nothing about + migration. +- **SC-3**: `Zod` rejects malformed `config.json` (e.g. `source: "github"` without + `repo`) with a clear error pointing at the offending path. +- **SC-4**: Re-running `ask docs add zod@` twice in a row leaves + `config.json` byte-identical and `ask.lock.entries.zod` byte-identical except for + `generatedAt` only updating when `contentHash` actually changed. +- **SC-5**: `ask docs sync` after a `bun update zod` re-fetches only zod, deletes the + old `.ask/docs/zod@/` after the new fetch succeeds, and updates the lock. + +## Risks + +- Migration timing: running migration on every invocation is annoying if it logs noisily; + use a sentinel file or check `.ask/` existence to ensure exactly-once. +- Hash computation cost on large doc trees — should still be sub-second but worth a + smoke test on a directory with thousands of files. +- npm `dist.integrity` format varies between registries — validate the regex is + permissive enough. + +## Out of scope + +- Changing the registry resolution priority order (already shipped: github > npm > web > llms-txt). +- Refactoring source adapters internal logic. +- Schema migration framework — punt to first version bump. diff --git a/.please/scripts/.gitkeep b/.please/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.please/templates/.gitkeep b/.please/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ca641b..a3bc9d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,7 +26,7 @@ Developer runs: ask docs add next@canary └──────────┘ └────────┘ └──────┘ │ │ │ ▼ ▼ ▼ - Config from Download .please/docs/ + Config from Download .ask/docs/ API or flags docs AGENTS.md .claude/skills/ ``` @@ -54,8 +54,8 @@ src/ │ ├── npm.ts # NpmSource — downloads npm tarballs, extracts docs │ ├── github.ts # GithubSource — downloads GitHub repo archives │ └── web.ts # WebSource — crawls HTML, converts to Markdown -├── storage.ts # Saves docs to .please/docs/@/, creates INDEX.md -├── config.ts # Persists source config to .please/config.json +├── storage.ts # Saves docs to .ask/docs/@/, creates INDEX.md +├── config.ts # Persists source config to .ask/config.json ├── skill.ts # Generates .claude/skills/-docs/SKILL.md └── agents.ts # Generates/updates AGENTS.md + references in CLAUDE.md ``` @@ -65,8 +65,8 @@ src/ 1. **Parse** — `index.ts` parses spec string (`npm:zod@3.22` → ecosystem, name, version) 2. **Resolve** — `registry.ts` fetches strategy from Registry API (if no `--source` flag) 3. **Fetch** — `sources/*.ts` downloads docs via the appropriate adapter -4. **Store** — `storage.ts` writes files to `.please/docs/@/` -5. **Configure** — `config.ts` saves entry to `.please/config.json` for later `sync` +4. **Store** — `storage.ts` writes files to `.ask/docs/@/` +5. **Configure** — `config.ts` saves entry to `.ask/config.json` for later `sync` 6. **Skill** — `skill.ts` generates `.claude/skills/-docs/SKILL.md` 7. **Agents** — `agents.ts` updates `AGENTS.md` with all downloaded doc references diff --git a/README.md b/README.md index 1fe0df8..34cec17 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This single command: 1. Looks up the library in the [ASK Registry](https://ask-registry.pages.dev) for optimal source config 2. Downloads the library's documentation from the resolved source -3. Saves it to `.please/docs/@/` +3. Saves it to `.ask/docs/@/` 4. Creates a Claude Code skill in `.claude/skills/-docs/SKILL.md` 5. Generates/updates `AGENTS.md` with instructions for AI agents @@ -58,7 +58,8 @@ ask docs add mylib@1.0 -s web --url https://mylib.dev/docs ask docs sync ``` -Re-downloads all libraries listed in `.please/config.json`. +Re-downloads all libraries listed in `.ask/config.json` and updates `.ask/ask.lock`. +Entries whose content has not changed since the last fetch are skipped. ### List downloaded docs diff --git a/bun.lock b/bun.lock index 71a67d3..4d305eb 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "citty": "^0.2.2", "consola": "^3.4.2", "node-html-markdown": "^1.3.0", + "zod": "^3.23.0", }, "devDependencies": { "@pleaseai/eslint-config": "^0.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index f042ae1..796f0b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,12 +19,14 @@ "build": "tsc", "dev": "tsc --watch", "lint": "eslint", - "lint:fix": "eslint --fix" + "lint:fix": "eslint --fix", + "test": "bun test" }, "dependencies": { "citty": "^0.2.2", "consola": "^3.4.2", - "node-html-markdown": "^1.3.0" + "node-html-markdown": "^1.3.0", + "zod": "^3.23.0" }, "devDependencies": { "@pleaseai/eslint-config": "^0.0.1", diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index f00c610..6414025 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,30 +1,15 @@ +import type { Config } from './schemas.js' import type { SourceConfig } from './sources/index.js' -import fs from 'node:fs' -import path from 'node:path' +import { readConfig, writeConfig } from './io.js' -export interface AskConfig { - docs: SourceConfig[] -} - -const DEFAULT_CONFIG: AskConfig = { docs: [] } - -export function getConfigPath(projectDir: string): string { - return path.join(projectDir, '.please', 'config.json') -} +export type AskConfig = Config export function loadConfig(projectDir: string): AskConfig { - const configPath = getConfigPath(projectDir) - if (!fs.existsSync(configPath)) { - return { ...DEFAULT_CONFIG } - } - const raw = fs.readFileSync(configPath, 'utf-8') - return JSON.parse(raw) as AskConfig + return readConfig(projectDir) } export function saveConfig(projectDir: string, config: AskConfig): void { - const configPath = getConfigPath(projectDir) - fs.mkdirSync(path.dirname(configPath), { recursive: true }) - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8') + writeConfig(projectDir, config) } export function addDocEntry( @@ -32,10 +17,9 @@ export function addDocEntry( entry: SourceConfig, ): AskConfig { const config = loadConfig(projectDir) - // Replace existing entry for same name@version - const idx = config.docs.findIndex( - d => d.name === entry.name && d.version === entry.version, - ) + // Replace existing entry for same name (regardless of version — versions + // change over time and we keep one entry per library) + const idx = config.docs.findIndex(d => d.name === entry.name) if (idx >= 0) { config.docs[idx] = entry } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8f847af..11ec221 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,12 +1,15 @@ #!/usr/bin/env node +import type { LockEntry } from './schemas.js' import type { + FetchResult, GithubSourceOptions, LlmsTxtSourceOptions, NpmSourceOptions, SourceConfig, WebSourceOptions, } from './sources/index.js' +import path from 'node:path' import process from 'node:process' import { defineCommand, runMain } from 'citty' import { consola } from 'consola' @@ -16,11 +19,60 @@ import { loadConfig, removeDocEntry, } from './config.js' +import { contentHash, getConfigPath, getLockPath, readLock, upsertLockEntry } from './io.js' +import { migrateLegacyWorkspace } from './migrate-legacy.js' import { parseEcosystem, resolveFromRegistry } from './registry.js' import { generateSkill, removeSkill } from './skill.js' import { getSource } from './sources/index.js' import { listDocs, removeDocs, saveDocs } from './storage.js' +function buildLockEntry(config: SourceConfig, result: FetchResult): LockEntry { + const base = { + version: result.resolvedVersion, + fetchedAt: new Date().toISOString(), + fileCount: result.files.length, + contentHash: contentHash( + result.files.map(f => ({ relpath: f.path, content: f.content })), + ), + } + const meta = result.meta ?? {} + switch (config.source) { + case 'github': + return { + ...base, + source: 'github', + repo: config.repo, + ref: meta.ref ?? config.tag ?? config.branch ?? 'main', + ...(meta.commit ? { commit: meta.commit } : {}), + } + case 'npm': + if (!meta.tarball) { + throw new Error( + `npm source did not return a tarball URL for ${config.name}@${result.resolvedVersion}. ` + + 'Cannot record lockfile entry without it.', + ) + } + return { + ...base, + source: 'npm', + tarball: meta.tarball, + ...(meta.integrity ? { integrity: meta.integrity } : {}), + } + case 'web': + return { + ...base, + source: 'web', + urls: meta.urls ?? config.urls, + } + case 'llms-txt': + return { + ...base, + source: 'llms-txt', + url: (meta.urls ?? [])[0] ?? config.url, + } + } +} + function parseSpec(spec: string): { name: string, version: string } { const lastAt = spec.lastIndexOf('@') if (lastAt > 0) { @@ -102,6 +154,7 @@ const addCmd = defineCommand({ }, async run({ args }) { const projectDir = process.cwd() + migrateLegacyWorkspace(projectDir) const { spec: cleanSpec } = parseEcosystem(args.spec) let sourceConfig: SourceConfig @@ -153,7 +206,11 @@ const addCmd = defineCommand({ const configEntry = { ...sourceConfig, version: result.resolvedVersion } addDocEntry(projectDir, configEntry) - consola.info('Config updated: .please/config.json') + consola.info(`Config updated: ${path.relative(projectDir, getConfigPath(projectDir))}`) + + const lockEntry = buildLockEntry(sourceConfig, result) + upsertLockEntry(projectDir, libName, lockEntry) + consola.info(`Lock updated: ${path.relative(projectDir, getLockPath(projectDir))}`) const skillPath = generateSkill( projectDir, @@ -171,41 +228,74 @@ const addCmd = defineCommand({ }) const syncCmd = defineCommand({ - meta: { name: 'sync', description: 'Download/update all docs from .please/config.json' }, + meta: { name: 'sync', description: 'Refresh docs from .ask/config.json, using .ask/ask.lock as the drift baseline' }, async run() { const projectDir = process.cwd() + migrateLegacyWorkspace(projectDir) const config = loadConfig(projectDir) + const lock = readLock(projectDir) if (config.docs.length === 0) { - consola.info('No docs configured in .please/config.json') + consola.info('No docs configured in .ask/config.json') return } consola.start(`Syncing ${config.docs.length} library docs...`) + let drifted = 0 + let unchanged = 0 + let failed = 0 + for (const entry of config.docs) { try { - consola.info(` ${entry.name}@${entry.version} (${entry.source})...`) + consola.info(` ${entry.name} (${entry.source})...`) const source = getSource(entry.source) const result = await source.fetch(entry) - + const newLockEntry = buildLockEntry(entry, result) + const previousLock = lock.entries[entry.name] + const changed = !previousLock + || previousLock.contentHash !== newLockEntry.contentHash + || previousLock.version !== newLockEntry.version + + if (!changed) { + unchanged++ + consola.info(` -> unchanged (v${result.resolvedVersion})`) + continue + } + + // Drift confirmed. Order is intentional: every write that can throw + // (saveDocs, addDocEntry/Zod, upsertLockEntry/Zod) happens BEFORE the + // destructive removeDocs of the old version. A mid-flow failure + // leaves both directories on disk and the config/lock pointing at + // the old version — recoverable on the next sync. Reversing this + // order would let a failed write leave config pointing at a deleted + // directory. saveDocs(projectDir, entry.name, result.resolvedVersion, result.files) + addDocEntry(projectDir, { ...entry, version: result.resolvedVersion }) + upsertLockEntry(projectDir, entry.name, newLockEntry) + if (previousLock && previousLock.version !== result.resolvedVersion) { + removeDocs(projectDir, entry.name, previousLock.version) + } generateSkill( projectDir, entry.name, result.resolvedVersion, result.files.map(f => f.path), ) - - consola.success(` -> ${result.files.length} files (v${result.resolvedVersion})`) + drifted++ + const fromVersion = previousLock?.version ?? '(new)' + consola.success(` ⟳ ${fromVersion} → ${result.resolvedVersion} (${result.files.length} files)`) } catch (err) { + failed++ consola.error(` -> Error: ${err instanceof Error ? err.message : err}`) } } generateAgentsMd(projectDir) - consola.success('Sync complete. AGENTS.md updated.') + consola.success( + `Sync complete: ${drifted} re-fetched, ${unchanged} unchanged, ${failed} failed. AGENTS.md updated.`, + ) }, }) @@ -213,6 +303,7 @@ const listCmd = defineCommand({ meta: { name: 'list', description: 'List downloaded documentation' }, run() { const projectDir = process.cwd() + migrateLegacyWorkspace(projectDir) const entries = listDocs(projectDir) if (entries.length === 0) { @@ -234,6 +325,7 @@ const removeCmd = defineCommand({ }, run({ args }) { const projectDir = process.cwd() + migrateLegacyWorkspace(projectDir) const { name, version } = parseSpec(args.spec) const hasExplicitVersion = args.spec.lastIndexOf('@') > 0 const ver = hasExplicitVersion ? version : undefined diff --git a/packages/cli/src/io.ts b/packages/cli/src/io.ts new file mode 100644 index 0000000..6cbd217 --- /dev/null +++ b/packages/cli/src/io.ts @@ -0,0 +1,206 @@ +import type { Config, Lock, LockEntry } from './schemas.js' +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { ConfigSchema, LockSchema } from './schemas.js' + +/** + * Recursively sort object keys for deterministic JSON serialization. + * Arrays preserve their element order; only object keys are reordered. + */ +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortKeys) + } + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => [k, sortKeys(v)] as const) + return Object.fromEntries(entries) + } + return value +} + +/** + * Serialize a value to JSON with sorted keys, 2-space indent, and a trailing + * newline. Two calls with semantically equivalent input always produce the + * same byte string. + */ +export function sortedJSON(value: unknown): string { + return `${JSON.stringify(sortKeys(value), null, 2)}\n` +} + +const ENCODER = new TextEncoder() +const NUL = new Uint8Array([0]) + +/** + * Compute a deterministic content hash over a list of files. + * Files are sorted by relative path; each file contributes + * `\0\0` to the hash stream. The null separators prevent + * `path + content` ambiguity (e.g. "ab" + "cd" vs "a" + "bcd"). + * + * Accepts either pre-encoded bytes or string content (the common case for + * DocFile records returned by source adapters). + */ +export interface HashableFile { + relpath: string + bytes?: Uint8Array + content?: string +} + +export function contentHash(files: HashableFile[]): string { + const sorted = [...files].sort((a, b) => + a.relpath < b.relpath ? -1 : a.relpath > b.relpath ? 1 : 0, + ) + const hash = createHash('sha256') + for (const f of sorted) { + hash.update(ENCODER.encode(f.relpath)) + hash.update(NUL) + hash.update(f.bytes ?? ENCODER.encode(f.content ?? '')) + hash.update(NUL) + } + return `sha256-${hash.digest('hex')}` +} + +const ASK_DIR = '.ask' +const CONFIG_FILE = 'config.json' +const LOCK_FILE = 'ask.lock' + +export function getAskDir(projectDir: string): string { + return path.join(projectDir, ASK_DIR) +} + +export function getConfigPath(projectDir: string): string { + return path.join(getAskDir(projectDir), CONFIG_FILE) +} + +export function getLockPath(projectDir: string): string { + return path.join(getAskDir(projectDir), LOCK_FILE) +} + +/** + * Read and validate `.ask/config.json`. Returns the default empty config when + * the file does not exist. Throws on invalid contents. + */ +export function readConfig(projectDir: string): Config { + const file = getConfigPath(projectDir) + if (!fs.existsSync(file)) { + return { schemaVersion: 1, docs: [] } + } + const raw = fs.readFileSync(file, 'utf-8') + let parsed: unknown + try { + parsed = JSON.parse(raw) + } + catch (err) { + throw new Error( + `Failed to parse ${file}: ${err instanceof Error ? err.message : err}. ` + + 'The file may be corrupt — delete it and re-run `ask docs sync` to regenerate.', + ) + } + return ConfigSchema.parse(parsed) +} + +/** + * Validate, sort, and write `.ask/config.json`. Sorts `docs[]` by name. + * Throws (without writing) if the input fails Zod validation. + */ +export function writeConfig(projectDir: string, config: Config): void { + const validated = ConfigSchema.parse(config) + const sortedDocs = [...validated.docs].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ) + const out: Config = { ...validated, docs: sortedDocs } + const file = getConfigPath(projectDir) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, sortedJSON(out), 'utf-8') +} + +/** + * Read and validate `.ask/ask.lock`. Returns the default empty lock when the + * file does not exist. Throws on invalid contents. + */ +export function readLock(projectDir: string): Lock { + const file = getLockPath(projectDir) + if (!fs.existsSync(file)) { + return { + lockfileVersion: 1, + generatedAt: '1970-01-01T00:00:00Z', + entries: {}, + } + } + const raw = fs.readFileSync(file, 'utf-8') + let parsed: unknown + try { + parsed = JSON.parse(raw) + } + catch (err) { + throw new Error( + `Failed to parse ${file}: ${err instanceof Error ? err.message : err}. ` + + 'The file may be corrupt — delete it and re-run `ask docs sync` to regenerate.', + ) + } + return LockSchema.parse(parsed) +} + +/** + * Validate, sort, and write `.ask/ask.lock`. Throws (without writing) if the + * input fails Zod validation. + */ +export function writeLock(projectDir: string, lock: Lock): void { + const validated = LockSchema.parse(lock) + const file = getLockPath(projectDir) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, sortedJSON(validated), 'utf-8') +} + +/** + * Upsert a single entry into `.ask/ask.lock`. Updates `generatedAt` only when + * the entry actually changes (so byte-stable on no-op re-runs). + */ +export function upsertLockEntry( + projectDir: string, + name: string, + entry: LockEntry, +): void { + const lock = readLock(projectDir) + const previous = lock.entries[name] + const changed = !previous + || sortedJSON(stripFetchedAt(previous)) !== sortedJSON(stripFetchedAt(entry)) + // No-op short circuit: if nothing changed (modulo fetchedAt), don't rewrite + // the file at all. This preserves mtime for build caches and file watchers. + if (!changed) { + return + } + writeLock(projectDir, { + lockfileVersion: 1, + generatedAt: new Date().toISOString(), + entries: { ...lock.entries, [name]: entry }, + }) +} + +function stripFetchedAt(entry: LockEntry): Omit { + const { fetchedAt: _, ...rest } = entry + return rest as Omit +} + +/** + * Remove one or more entries from the lock by name. No-op if absent. + */ +export function removeLockEntries( + projectDir: string, + names: string[], +): void { + if (names.length === 0) + return + const lock = readLock(projectDir) + const set = new Set(names) + const remaining = Object.fromEntries( + Object.entries(lock.entries).filter(([k]) => !set.has(k)), + ) + writeLock(projectDir, { + lockfileVersion: 1, + generatedAt: new Date().toISOString(), + entries: remaining, + }) +} diff --git a/packages/cli/src/migrate-legacy.ts b/packages/cli/src/migrate-legacy.ts new file mode 100644 index 0000000..242be52 --- /dev/null +++ b/packages/cli/src/migrate-legacy.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs' +import path from 'node:path' +import { consola } from 'consola' +import { getAskDir, getConfigPath, writeConfig } from './io.js' +import { ConfigSchema } from './schemas.js' + +const LEGACY_DIR = '.please' + +/** + * One-shot migration from `.please/` (used by ASK before April 2026) to + * `.ask/`. Triggered exactly once on the first CLI invocation in a project + * that still has the legacy layout. + * + * Idempotency sentinel: the presence of `.ask/config.json` (NOT just `.ask/`) + * marks the migration as complete. A bare `.ask/` directory may exist for + * other reasons (created by another tool, leftover from a partial run), so + * we require the config file specifically. + * + * Order of operations is critical for safety: + * 1. Parse and validate the legacy config FIRST (in memory). + * 2. Write the new config to .ask/config.json — atomic-ish, no destructive + * side effects yet. + * 3. Move .please/docs/* into .ask/docs/. + * 4. Remove the legacy config file. + * + * If step 1 or 2 throws, nothing on disk has changed and the next run can + * retry. If step 3 throws partway through, we leave the partial state and + * throw — the user must intervene rather than silently losing entries on + * the next run. + */ +export function migrateLegacyWorkspace(projectDir: string): void { + const newDir = getAskDir(projectDir) + const newConfig = getConfigPath(projectDir) + + if (fs.existsSync(newConfig)) { + return + } + + const legacyDir = path.join(projectDir, LEGACY_DIR) + const legacyDocs = path.join(legacyDir, 'docs') + const legacyConfig = path.join(legacyDir, 'config.json') + const hasLegacyDocs = fs.existsSync(legacyDocs) + const hasLegacyConfig = fs.existsSync(legacyConfig) + + if (!hasLegacyDocs && !hasLegacyConfig) { + return + } + + consola.warn( + 'ASK legacy layout detected (.please/). Migrating to .ask/. ' + + 'This is a one-time operation; future runs will not log this message.', + ) + + let migratedConfig: ReturnType | null = null + if (hasLegacyConfig) { + try { + const raw = fs.readFileSync(legacyConfig, 'utf-8') + const legacy = JSON.parse(raw) as { docs?: unknown[] } + migratedConfig = ConfigSchema.parse({ + schemaVersion: 1, + docs: legacy.docs ?? [], + }) + } + catch (err) { + throw new Error( + `Failed to parse legacy .please/config.json: ${err instanceof Error ? err.message : err}. ` + + 'The legacy file is left intact for manual recovery — fix or delete it and re-run.', + ) + } + } + + fs.mkdirSync(newDir, { recursive: true }) + writeConfig(projectDir, migratedConfig ?? { schemaVersion: 1, docs: [] }) + + if (hasLegacyDocs) { + const newDocs = path.join(newDir, 'docs') + fs.mkdirSync(newDocs, { recursive: true }) + try { + for (const entry of fs.readdirSync(legacyDocs)) { + fs.renameSync( + path.join(legacyDocs, entry), + path.join(newDocs, entry), + ) + } + fs.rmSync(legacyDocs, { recursive: true, force: true }) + } + catch (err) { + throw new Error( + `Failed to move legacy docs from .please/docs to .ask/docs: ${err instanceof Error ? err.message : err}. ` + + 'Workspace is now in a partial state — please move any remaining entries manually.', + ) + } + } + + if (hasLegacyConfig) { + try { + fs.rmSync(legacyConfig, { force: true }) + } + catch { + // best-effort: the new config is the sentinel, not the absence of the old one + } + } +} diff --git a/packages/cli/src/schemas.ts b/packages/cli/src/schemas.ts new file mode 100644 index 0000000..e824c77 --- /dev/null +++ b/packages/cli/src/schemas.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' + +const NameField = z.string().min(1) +const VersionField = z.string().min(1) + +// Git ref names must match a conservative subset to prevent shell-injection or +// path-traversal style mischief — git itself permits broader characters but +// the ASK lockfile only needs alphanumerics, dot, slash, underscore, hyphen. +const GitRefField = z.string().regex( + /^[\w./-]+$/, + 'git ref must contain only [A-Za-z0-9 _ . / -]', +) + +const GithubSourceConfig = z.object({ + source: z.literal('github'), + name: NameField, + version: VersionField, + repo: z.string().regex(/^[^/]+\/[^/]+$/, 'repo must be in "owner/name" form'), + branch: GitRefField.optional(), + tag: GitRefField.optional(), + docsPath: z.string().optional(), +}) + +const NpmSourceConfig = z.object({ + source: z.literal('npm'), + name: NameField, + version: VersionField, + package: z.string().optional(), + docsPath: z.string().optional(), +}) + +const WebSourceConfig = z.object({ + source: z.literal('web'), + name: NameField, + version: VersionField, + urls: z.array(z.string().url()).min(1), + maxDepth: z.number().int().min(0).optional(), + allowedPathPrefix: z.string().optional(), +}) + +const LlmsTxtSourceConfig = z.object({ + source: z.literal('llms-txt'), + name: NameField, + version: VersionField, + url: z.string().url(), +}) + +export const SourceConfigSchema = z.discriminatedUnion('source', [ + GithubSourceConfig, + NpmSourceConfig, + WebSourceConfig, + LlmsTxtSourceConfig, +]) + +export type SourceConfig = z.infer + +export const ConfigSchema = z.object({ + schemaVersion: z.literal(1), + docs: z.array(SourceConfigSchema), +}) + +export type Config = z.infer + +const ContentHashField = z.string().regex( + /^sha256-[0-9a-f]{64}$/, + 'contentHash must be sha256-<64 hex chars>', +) + +const IsoDateTimeField = z.string().datetime({ offset: true }) + +const LockEntryBase = { + version: VersionField, + fetchedAt: IsoDateTimeField, + fileCount: z.number().int().nonnegative(), + contentHash: ContentHashField, +} + +const GithubLockEntry = z.object({ + ...LockEntryBase, + source: z.literal('github'), + repo: z.string(), + ref: z.string(), + commit: z.string().regex(/^[0-9a-f]{40}$/).optional(), +}) + +const NpmLockEntry = z.object({ + ...LockEntryBase, + source: z.literal('npm'), + tarball: z.string().url(), + integrity: z.string().regex( + /^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/, + 'integrity must be a valid Subresource Integrity hash', + ).optional(), +}) + +const WebLockEntry = z.object({ + ...LockEntryBase, + source: z.literal('web'), + urls: z.array(z.string().url()).min(1), +}) + +const LlmsTxtLockEntry = z.object({ + ...LockEntryBase, + source: z.literal('llms-txt'), + url: z.string().url(), +}) + +export const LockEntrySchema = z.discriminatedUnion('source', [ + GithubLockEntry, + NpmLockEntry, + WebLockEntry, + LlmsTxtLockEntry, +]) + +export type LockEntry = z.infer + +export const LockSchema = z.object({ + lockfileVersion: z.literal(1), + generatedAt: IsoDateTimeField, + entries: z.record(z.string(), LockEntrySchema), +}) + +export type Lock = z.infer diff --git a/packages/cli/src/sources/github.ts b/packages/cli/src/sources/github.ts index f5f356b..5256b76 100644 --- a/packages/cli/src/sources/github.ts +++ b/packages/cli/src/sources/github.ts @@ -5,12 +5,15 @@ import type { GithubSourceOptions, SourceConfig, } from './index.js' -import { execSync } from 'node:child_process' +import { execFileSync, execSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { consola } from 'consola' const RE_LEADING_V = /^v/ +const RE_SHA40 = /^[0-9a-f]{40}$/ +const RE_WHITESPACE = /\s+/ export class GithubSource implements DocSource { async fetch(options: SourceConfig): Promise { @@ -60,7 +63,13 @@ export class GithubSource implements DocSource { else { files = this.collectDocFiles(docsDir, docsDir) } - return { files, resolvedVersion } + + const commit = this.resolveCommit(repo, ref) + return { + files, + resolvedVersion, + meta: { commit, ref }, + } } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) @@ -97,6 +106,37 @@ export class GithubSource implements DocSource { return files } + /** + * Resolve a ref (tag or branch) to a full commit sha via `git ls-remote`. + * Returns undefined when git is unavailable or the ref cannot be resolved + * — the lockfile leaves `commit` undefined rather than guessing. + */ + private resolveCommit(repo: string, ref: string): string | undefined { + try { + // execFileSync (not execSync) to bypass the shell — `ref` originates + // from user-supplied tag/branch and must not be interpolated into a + // shell command line. + const out = execFileSync( + 'git', + ['ls-remote', `https://github.com/${repo}.git`, ref], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim() + // ls-remote may return multiple lines (e.g. tag + ^{} dereference). + // Prefer the dereferenced commit if present. + const lines = out.split('\n').filter(Boolean) + const dereferenced = lines.find(l => l.includes(`refs/tags/${ref}^{}`)) + const sha = (dereferenced ?? lines[0])?.split(RE_WHITESPACE)[0] + return sha && RE_SHA40.test(sha) ? sha : undefined + } + catch (err) { + consola.warn( + `Could not resolve commit for ${repo}@${ref}: ${err instanceof Error ? err.message : err}. ` + + 'Lockfile will not pin a commit sha for this entry.', + ) + return undefined + } + } + private isDocFile(filename: string): boolean { const ext = path.extname(filename).toLowerCase() return ['.md', '.mdx', '.txt', '.rst'].includes(ext) diff --git a/packages/cli/src/sources/index.ts b/packages/cli/src/sources/index.ts index 979ed2b..26d4679 100644 --- a/packages/cli/src/sources/index.ts +++ b/packages/cli/src/sources/index.ts @@ -1,8 +1,20 @@ +import type { SourceConfig } from '../schemas.js' import { GithubSource } from './github.js' import { LlmsTxtSource } from './llms-txt.js' import { NpmSource } from './npm.js' import { WebSource } from './web.js' +export type { SourceConfig } + +// Re-export per-source variants as Extract<> aliases over the Zod-inferred +// union. This makes the Zod schema in `../schemas.ts` the single source of +// truth for runtime invariants — the CLI cannot construct a SourceConfig +// that satisfies the type but fails Zod validation downstream. +export type NpmSourceOptions = Extract +export type GithubSourceOptions = Extract +export type WebSourceOptions = Extract +export type LlmsTxtSourceOptions = Extract + export interface DocFile { path: string content: string @@ -11,62 +23,34 @@ export interface DocFile { export interface FetchResult { files: DocFile[] resolvedVersion: string + /** Source-specific metadata propagated to ask.lock */ + meta?: { + /** GitHub commit sha (40 hex chars) */ + commit?: string + /** GitHub ref used (tag name or branch name) */ + ref?: string + /** npm Subresource Integrity hash from dist.integrity */ + integrity?: string + /** npm tarball URL */ + tarball?: string + /** web/llms-txt source URL(s) */ + urls?: string[] + } } -export interface DocSourceOptions { - name: string - version: string -} - -export interface NpmSourceOptions extends DocSourceOptions { - source: 'npm' - package?: string - docsPath?: string -} - -export interface GithubSourceOptions extends DocSourceOptions { - source: 'github' - repo: string - branch?: string - tag?: string - docsPath?: string -} - -export interface WebSourceOptions extends DocSourceOptions { - source: 'web' - urls: string[] - maxDepth?: number - allowedPathPrefix?: string -} - -export interface LlmsTxtSourceOptions extends DocSourceOptions { - source: 'llms-txt' - url: string -} - -export type SourceConfig - = | NpmSourceOptions - | GithubSourceOptions - | WebSourceOptions - | LlmsTxtSourceOptions - export interface DocSource { fetch: (options: SourceConfig) => Promise } -const sources: Record = { +type SourceKind = SourceConfig['source'] + +const sources: Record = { 'npm': new NpmSource(), 'github': new GithubSource(), 'web': new WebSource(), 'llms-txt': new LlmsTxtSource(), } -export function getSource(type: string): DocSource { - const source = sources[type] - if (!source) { - throw new Error( - `Unknown source type: ${type}. Available: ${Object.keys(sources).join(', ')}`, - ) - } - return source +export function getSource(type: SourceKind): DocSource { + return sources[type] } diff --git a/packages/cli/src/sources/npm.ts b/packages/cli/src/sources/npm.ts index 04575c8..504ebf1 100644 --- a/packages/cli/src/sources/npm.ts +++ b/packages/cli/src/sources/npm.ts @@ -26,6 +26,17 @@ export class NpmSource implements DocSource { encoding: 'utf-8', }).trim() + const integrity = (() => { + try { + return execSync(`npm view ${spec} dist.integrity`, { + encoding: 'utf-8', + }).trim() || undefined + } + catch { + return undefined + } + })() + // Download and extract tarball const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-npm-')) @@ -60,7 +71,11 @@ export class NpmSource implements DocSource { else { files = this.collectMarkdownFiles(docsDir, docsDir) } - return { files, resolvedVersion } + return { + files, + resolvedVersion, + meta: { tarball: tarballUrl, integrity }, + } } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) diff --git a/packages/cli/src/storage.ts b/packages/cli/src/storage.ts index 05d420b..e559062 100644 --- a/packages/cli/src/storage.ts +++ b/packages/cli/src/storage.ts @@ -1,9 +1,10 @@ import type { DocFile } from './sources/index.js' import fs from 'node:fs' import path from 'node:path' +import { getAskDir } from './io.js' export function getDocsDir(projectDir: string): string { - return path.join(projectDir, '.please', 'docs') + return path.join(getAskDir(projectDir), 'docs') } export function getLibraryDocsDir( diff --git a/packages/cli/test/agents.test.ts b/packages/cli/test/agents.test.ts new file mode 100644 index 0000000..d851660 --- /dev/null +++ b/packages/cli/test/agents.test.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { generateAgentsMd } from '../src/agents.js' +import { saveDocs } from '../src/storage.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-agents-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('generateAgentsMd', () => { + it('writes nothing when there are no docs', () => { + const result = generateAgentsMd(tmpDir) + expect(result).toBe('') + expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false) + }) + + it('references .ask/docs (not .please/docs) in the marker block', () => { + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8') + expect(content).toContain('.ask/docs/zod@3.22.4') + expect(content).not.toContain('.please/docs/') + expect(content).toContain('') + expect(content).toContain('') + }) + + it('preserves user content outside the marker block', () => { + const agentsPath = path.join(tmpDir, 'AGENTS.md') + fs.writeFileSync( + agentsPath, + '# My Project\n\nUser-written notes.\n\n\nold\n\n\nMore notes.\n', + ) + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + const updated = fs.readFileSync(agentsPath, 'utf-8') + expect(updated).toContain('# My Project') + expect(updated).toContain('User-written notes.') + expect(updated).toContain('More notes.') + expect(updated).toContain('.ask/docs/zod@3.22.4') + expect(updated).not.toContain('\nold\n') + }) + + it('creates CLAUDE.md with @AGENTS.md when missing', () => { + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + const claude = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8') + expect(claude).toContain('@AGENTS.md') + }) + + it('does not duplicate @AGENTS.md when CLAUDE.md already references it', () => { + fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Project\n@AGENTS.md\n') + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + const claude = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8') + const occurrences = (claude.match(/@AGENTS\.md/g) ?? []).length + expect(occurrences).toBe(1) + }) +}) diff --git a/packages/cli/test/io-helpers.test.ts b/packages/cli/test/io-helpers.test.ts new file mode 100644 index 0000000..596a41c --- /dev/null +++ b/packages/cli/test/io-helpers.test.ts @@ -0,0 +1,241 @@ +import type { Config, Lock } from '../src/schemas.js' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + readConfig, + readLock, + writeConfig, + writeLock, +} from '../src/io.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-io-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('readConfig / writeConfig', () => { + it('returns a default empty config when file does not exist', () => { + const cfg = readConfig(tmpDir) + expect(cfg.schemaVersion).toBe(1) + expect(cfg.docs).toEqual([]) + }) + + it('round-trips a valid config', () => { + const cfg: Config = { + schemaVersion: 1, + docs: [ + { + source: 'github', + name: 'zod', + version: '3.22.4', + repo: 'colinhacks/zod', + tag: 'v3.22.4', + }, + ], + } + writeConfig(tmpDir, cfg) + const out = readConfig(tmpDir) + expect(out).toEqual(cfg) + }) + + it('sorts docs[] by name on write', () => { + const cfg: Config = { + schemaVersion: 1, + docs: [ + { source: 'npm', name: 'zod', version: '3.22.4' }, + { source: 'npm', name: 'hono', version: '4.6.5' }, + { source: 'npm', name: 'drizzle-orm', version: '0.36.0' }, + ], + } + writeConfig(tmpDir, cfg) + const raw = fs.readFileSync(path.join(tmpDir, '.ask', 'config.json'), 'utf-8') + const idxDrizzle = raw.indexOf('drizzle-orm') + const idxHono = raw.indexOf('hono') + const idxZod = raw.indexOf('zod') + expect(idxDrizzle).toBeLessThan(idxHono) + expect(idxHono).toBeLessThan(idxZod) + }) + + it('produces byte-identical output on round-trip', () => { + const cfg: Config = { + schemaVersion: 1, + docs: [ + { + source: 'github', + name: 'zod', + version: '3.22.4', + repo: 'colinhacks/zod', + tag: 'v3.22.4', + }, + ], + } + writeConfig(tmpDir, cfg) + const first = fs.readFileSync( + path.join(tmpDir, '.ask', 'config.json'), + 'utf-8', + ) + const reread = readConfig(tmpDir) + writeConfig(tmpDir, reread) + const second = fs.readFileSync( + path.join(tmpDir, '.ask', 'config.json'), + 'utf-8', + ) + expect(second).toBe(first) + }) + + it('throws on invalid config (e.g. github source missing repo)', () => { + const configPath = path.join(tmpDir, '.ask', 'config.json') + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync( + configPath, + JSON.stringify({ + schemaVersion: 1, + docs: [{ source: 'github', name: 'x', version: '1.0.0' }], + }), + ) + expect(() => readConfig(tmpDir)).toThrow() + }) + + it('writeConfig throws on invalid config without writing', () => { + expect(() => + writeConfig(tmpDir, { schemaVersion: 99 as 1, docs: [] }), + ).toThrow() + expect(fs.existsSync(path.join(tmpDir, '.ask', 'config.json'))).toBe(false) + }) +}) + +describe('readLock / writeLock', () => { + it('returns a default empty lock when file does not exist', () => { + const lock = readLock(tmpDir) + expect(lock.lockfileVersion).toBe(1) + expect(lock.entries).toEqual({}) + }) + + it('round-trips a valid lock', () => { + const lock: Lock = { + lockfileVersion: 1, + generatedAt: '2026-04-07T06:00:00Z', + entries: { + zod: { + source: 'github', + version: '3.22.4', + repo: 'colinhacks/zod', + ref: 'v3.22.4', + commit: 'a'.repeat(40), + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 23, + contentHash: `sha256-${'a'.repeat(64)}`, + }, + }, + } + writeLock(tmpDir, lock) + const out = readLock(tmpDir) + expect(out).toEqual(lock) + }) + + it('produces byte-identical output on round-trip', () => { + const lock: Lock = { + lockfileVersion: 1, + generatedAt: '2026-04-07T06:00:00Z', + entries: { + b: { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/b/-/b-1.0.0.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 5, + contentHash: `sha256-${'b'.repeat(64)}`, + }, + a: { + source: 'npm', + version: '2.0.0', + tarball: 'https://registry.npmjs.org/a/-/a-2.0.0.tgz', + integrity: `sha512-${'C'.repeat(86)}==`, + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 7, + contentHash: `sha256-${'c'.repeat(64)}`, + }, + }, + } + writeLock(tmpDir, lock) + const first = fs.readFileSync( + path.join(tmpDir, '.ask', 'ask.lock'), + 'utf-8', + ) + const reread = readLock(tmpDir) + writeLock(tmpDir, reread) + const second = fs.readFileSync( + path.join(tmpDir, '.ask', 'ask.lock'), + 'utf-8', + ) + expect(second).toBe(first) + }) + + it('determinism stress: write→read→write is idempotent across many keys', () => { + const lock: Lock = { + lockfileVersion: 1, + generatedAt: '2026-04-07T06:00:00Z', + entries: Object.fromEntries( + Array.from({ length: 30 }, (_, i) => { + const name = `lib-${String(i).padStart(2, '0')}` + return [name, { + source: 'github' as const, + version: `${i}.0.0`, + repo: `owner/${name}`, + ref: `v${i}.0.0`, + commit: i.toString(16).padStart(40, '0'), + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: i, + contentHash: `sha256-${i.toString(16).padStart(64, '0')}`, + }] + }), + ), + } + writeLock(tmpDir, lock) + const a = fs.readFileSync(path.join(tmpDir, '.ask', 'ask.lock'), 'utf-8') + writeLock(tmpDir, readLock(tmpDir)) + const b = fs.readFileSync(path.join(tmpDir, '.ask', 'ask.lock'), 'utf-8') + writeLock(tmpDir, readLock(tmpDir)) + const c = fs.readFileSync(path.join(tmpDir, '.ask', 'ask.lock'), 'utf-8') + expect(b).toBe(a) + expect(c).toBe(a) + }) + + it('sorts entries map keys on write', () => { + const lock: Lock = { + lockfileVersion: 1, + generatedAt: '2026-04-07T06:00:00Z', + entries: { + zed: { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/zed/-/zed-1.0.0.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 1, + contentHash: `sha256-${'a'.repeat(64)}`, + }, + alpha: { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz', + integrity: `sha512-${'B'.repeat(86)}==`, + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 1, + contentHash: `sha256-${'b'.repeat(64)}`, + }, + }, + } + writeLock(tmpDir, lock) + const raw = fs.readFileSync(path.join(tmpDir, '.ask', 'ask.lock'), 'utf-8') + expect(raw.indexOf('alpha')).toBeLessThan(raw.indexOf('zed')) + }) +}) diff --git a/packages/cli/test/io-utils.test.ts b/packages/cli/test/io-utils.test.ts new file mode 100644 index 0000000..13562bc --- /dev/null +++ b/packages/cli/test/io-utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test' +import { contentHash, sortedJSON } from '../src/io.js' + +describe('sortedJSON', () => { + it('sorts top-level keys alphabetically', () => { + const out = sortedJSON({ b: 1, a: 2, c: 3 }) + expect(out).toBe('{\n "a": 2,\n "b": 1,\n "c": 3\n}\n') + }) + + it('sorts nested object keys', () => { + const out = sortedJSON({ z: { y: 1, x: 2 } }) + expect(out).toBe('{\n "z": {\n "x": 2,\n "y": 1\n }\n}\n') + }) + + it('preserves array element order', () => { + const out = sortedJSON([{ b: 1 }, { a: 2 }]) + expect(out).toBe('[\n {\n "b": 1\n },\n {\n "a": 2\n }\n]\n') + }) + + it('produces a trailing newline', () => { + expect(sortedJSON({ a: 1 }).endsWith('\n')).toBe(true) + }) + + it('is deterministic across calls with shuffled input', () => { + const a = sortedJSON({ a: 1, b: 2, c: 3 }) + const b = sortedJSON({ c: 3, b: 2, a: 1 }) + expect(a).toBe(b) + }) + + it('handles primitives and null', () => { + expect(sortedJSON(null)).toBe('null\n') + expect(sortedJSON(42)).toBe('42\n') + expect(sortedJSON('hi')).toBe('"hi"\n') + }) +}) + +describe('contentHash', () => { + it('returns sha256-<64hex>', () => { + const h = contentHash([ + { relpath: 'a.md', bytes: new TextEncoder().encode('hello') }, + ]) + expect(h).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + it('is order-independent (sorts by relpath)', () => { + const files = [ + { relpath: 'a.md', bytes: new TextEncoder().encode('A') }, + { relpath: 'b.md', bytes: new TextEncoder().encode('B') }, + { relpath: 'c.md', bytes: new TextEncoder().encode('C') }, + ] + const h1 = contentHash(files) + const h2 = contentHash([...files].reverse()) + expect(h1).toBe(h2) + }) + + it('changes when content changes', () => { + const a = contentHash([ + { relpath: 'a.md', bytes: new TextEncoder().encode('hello') }, + ]) + const b = contentHash([ + { relpath: 'a.md', bytes: new TextEncoder().encode('hello!') }, + ]) + expect(a).not.toBe(b) + }) + + it('changes when a file is added', () => { + const a = contentHash([ + { relpath: 'a.md', bytes: new TextEncoder().encode('A') }, + ]) + const b = contentHash([ + { relpath: 'a.md', bytes: new TextEncoder().encode('A') }, + { relpath: 'b.md', bytes: new TextEncoder().encode('B') }, + ]) + expect(a).not.toBe(b) + }) + + it('distinguishes path from content (separator prevents ambiguity)', () => { + const a = contentHash([ + { relpath: 'ab', bytes: new TextEncoder().encode('cd') }, + ]) + const b = contentHash([ + { relpath: 'a', bytes: new TextEncoder().encode('bcd') }, + ]) + expect(a).not.toBe(b) + }) +}) diff --git a/packages/cli/test/lock-pipeline.test.ts b/packages/cli/test/lock-pipeline.test.ts new file mode 100644 index 0000000..55f1c46 --- /dev/null +++ b/packages/cli/test/lock-pipeline.test.ts @@ -0,0 +1,187 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { addDocEntry, loadConfig } from '../src/config.js' +import { + contentHash, + readLock, + removeLockEntries, + upsertLockEntry, +} from '../src/io.js' +import { saveDocs } from '../src/storage.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-pipeline-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +function fakeFiles(): Array<{ path: string, content: string }> { + return [ + { path: 'README.md', content: '# fake' }, + { path: 'guide/intro.md', content: 'intro content' }, + ] +} + +function buildHash(files: Array<{ path: string, content: string }>) { + return contentHash( + files.map(f => ({ + relpath: f.path, + bytes: new TextEncoder().encode(f.content), + })), + ) +} + +describe('add pipeline (T013): saveDocs + addDocEntry + upsertLockEntry', () => { + it('produces .ask/docs, .ask/config.json, and .ask/ask.lock with consistent state', () => { + const files = fakeFiles() + saveDocs(tmpDir, 'fakelib', '1.0.0', files) + addDocEntry(tmpDir, { + source: 'github', + name: 'fakelib', + version: '1.0.0', + repo: 'fake/lib', + tag: 'v1.0.0', + }) + upsertLockEntry(tmpDir, 'fakelib', { + source: 'github', + version: '1.0.0', + repo: 'fake/lib', + ref: 'v1.0.0', + commit: 'a'.repeat(40), + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: files.length, + contentHash: buildHash(files), + }) + + expect(fs.existsSync(path.join(tmpDir, '.ask', 'docs', 'fakelib@1.0.0', 'README.md'))).toBe(true) + expect(fs.existsSync(path.join(tmpDir, '.ask', 'docs', 'fakelib@1.0.0', 'INDEX.md'))).toBe(true) + + const cfg = loadConfig(tmpDir) + expect(cfg.docs).toHaveLength(1) + expect(cfg.docs[0].name).toBe('fakelib') + + const lock = readLock(tmpDir) + expect(lock.entries.fakelib).toBeDefined() + expect(lock.entries.fakelib.contentHash).toBe(buildHash(files)) + }) + + it('SC-4: re-running with identical content leaves lock byte-stable (modulo fetchedAt)', () => { + const files = fakeFiles() + const baseEntry = { + source: 'npm' as const, + version: '1.0.0', + tarball: 'https://registry.npmjs.org/fake/-/fake-1.0.0.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + fileCount: files.length, + contentHash: buildHash(files), + } + upsertLockEntry(tmpDir, 'fake', { ...baseEntry, fetchedAt: '2026-04-07T06:00:00Z' }) + const first = fs.readFileSync(path.join(tmpDir, '.ask', 'ask.lock'), 'utf-8') + + // Second upsert with same content but different fetchedAt — generatedAt + // should NOT update (because the entry-modulo-fetchedAt is unchanged). + upsertLockEntry(tmpDir, 'fake', { ...baseEntry, fetchedAt: '2026-04-07T07:00:00Z' }) + const second = fs.readFileSync(path.join(tmpDir, '.ask', 'ask.lock'), 'utf-8') + + // generatedAt is preserved across the two writes + const firstParsed = JSON.parse(first) + const secondParsed = JSON.parse(second) + expect(secondParsed.generatedAt).toBe(firstParsed.generatedAt) + }) + + it('content change updates generatedAt and contentHash', () => { + const filesA = fakeFiles() + upsertLockEntry(tmpDir, 'fake', { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/fake/-/fake-1.0.0.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + fileCount: filesA.length, + contentHash: buildHash(filesA), + fetchedAt: '2026-04-07T06:00:00Z', + }) + const generatedAtA = readLock(tmpDir).generatedAt + + const filesB = [ + ...filesA, + { path: 'CHANGELOG.md', content: '## new' }, + ] + upsertLockEntry(tmpDir, 'fake', { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/fake/-/fake-1.0.0.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + fileCount: filesB.length, + contentHash: buildHash(filesB), + fetchedAt: '2026-04-07T06:00:00Z', + }) + const lockB = readLock(tmpDir) + expect(lockB.generatedAt).not.toBe(generatedAtA) + expect(lockB.entries.fake.contentHash).not.toBe(buildHash(filesA)) + }) +}) + +describe('sync pipeline (T014): drift classification', () => { + it('SC-5: bumping a version updates lock and lets us prune the old entry', () => { + // Initial state: fake@1.0.0 exists in lock + const filesV1 = fakeFiles() + upsertLockEntry(tmpDir, 'fake', { + source: 'github', + version: '1.0.0', + repo: 'fake/lib', + ref: 'v1.0.0', + commit: 'a'.repeat(40), + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: filesV1.length, + contentHash: buildHash(filesV1), + }) + + // Drift: version bump to 2.0.0 + const filesV2 = [{ path: 'README.md', content: '# v2' }] + upsertLockEntry(tmpDir, 'fake', { + source: 'github', + version: '2.0.0', + repo: 'fake/lib', + ref: 'v2.0.0', + commit: 'b'.repeat(40), + fetchedAt: '2026-04-07T07:00:00Z', + fileCount: filesV2.length, + contentHash: buildHash(filesV2), + }) + + const lock = readLock(tmpDir) + expect(lock.entries.fake.version).toBe('2.0.0') + expect(lock.entries.fake.contentHash).toBe(buildHash(filesV2)) + }) + + it('removeLockEntries prunes by name and is a no-op for unknown names', () => { + upsertLockEntry(tmpDir, 'a', { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/a/-/a-1.0.0.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 1, + contentHash: `sha256-${'a'.repeat(64)}`, + }) + upsertLockEntry(tmpDir, 'b', { + source: 'npm', + version: '1.0.0', + tarball: 'https://registry.npmjs.org/b/-/b-1.0.0.tgz', + integrity: `sha512-${'B'.repeat(86)}==`, + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 1, + contentHash: `sha256-${'b'.repeat(64)}`, + }) + removeLockEntries(tmpDir, ['a', 'unknown']) + const lock = readLock(tmpDir) + expect(lock.entries.a).toBeUndefined() + expect(lock.entries.b).toBeDefined() + }) +}) diff --git a/packages/cli/test/migrate-legacy.test.ts b/packages/cli/test/migrate-legacy.test.ts new file mode 100644 index 0000000..6711102 --- /dev/null +++ b/packages/cli/test/migrate-legacy.test.ts @@ -0,0 +1,114 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { readConfig } from '../src/io.js' +import { migrateLegacyWorkspace } from '../src/migrate-legacy.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-migrate-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +function seedLegacy(legacyConfig: object, libs: Record): void { + const please = path.join(tmpDir, '.please') + fs.mkdirSync(path.join(please, 'docs'), { recursive: true }) + fs.writeFileSync( + path.join(please, 'config.json'), + `${JSON.stringify(legacyConfig, null, 2)}\n`, + ) + for (const [dirName, content] of Object.entries(libs)) { + const libDir = path.join(please, 'docs', dirName) + fs.mkdirSync(libDir, { recursive: true }) + fs.writeFileSync(path.join(libDir, 'README.md'), content) + } +} + +describe('migrateLegacyWorkspace', () => { + it('is a no-op when neither .please nor .ask exists', () => { + migrateLegacyWorkspace(tmpDir) + expect(fs.existsSync(path.join(tmpDir, '.ask'))).toBe(false) + expect(fs.existsSync(path.join(tmpDir, '.please'))).toBe(false) + }) + + it('is a no-op when .ask/config.json already exists (sentinel)', () => { + // The sentinel is .ask/config.json specifically — a bare .ask/ directory + // could exist for unrelated reasons, so the migration only short-circuits + // when the new config file is in place. + fs.mkdirSync(path.join(tmpDir, '.ask'), { recursive: true }) + fs.writeFileSync( + path.join(tmpDir, '.ask', 'config.json'), + '{"schemaVersion":1,"docs":[]}\n', + ) + fs.mkdirSync(path.join(tmpDir, '.please', 'docs', 'foo@1.0.0'), { recursive: true }) + fs.writeFileSync(path.join(tmpDir, '.please', 'docs', 'foo@1.0.0', 'README.md'), 'old') + migrateLegacyWorkspace(tmpDir) + // .please/docs should still be there because migration was skipped + expect(fs.existsSync(path.join(tmpDir, '.please', 'docs', 'foo@1.0.0'))).toBe(true) + // .ask/docs not created because migration was skipped + expect(fs.existsSync(path.join(tmpDir, '.ask', 'docs'))).toBe(false) + }) + + it('moves .please/docs/* into .ask/docs/', () => { + seedLegacy( + { + docs: [ + { source: 'github', name: 'foo', version: '1.0.0', repo: 'a/foo', tag: 'v1.0.0' }, + ], + }, + { 'foo@1.0.0': '# foo' }, + ) + migrateLegacyWorkspace(tmpDir) + expect(fs.existsSync(path.join(tmpDir, '.ask', 'docs', 'foo@1.0.0', 'README.md'))).toBe(true) + expect(fs.existsSync(path.join(tmpDir, '.please', 'docs', 'foo@1.0.0'))).toBe(false) + }) + + it('parses legacy config and rewrites it under .ask/ with schemaVersion 1', () => { + seedLegacy( + { + docs: [ + { source: 'github', name: 'foo', version: '1.0.0', repo: 'a/foo', tag: 'v1.0.0' }, + { source: 'npm', name: 'bar', version: '2.0.0' }, + ], + }, + { 'foo@1.0.0': '# foo', 'bar@2.0.0': '# bar' }, + ) + migrateLegacyWorkspace(tmpDir) + const cfg = readConfig(tmpDir) + expect(cfg.schemaVersion).toBe(1) + expect(cfg.docs).toHaveLength(2) + // docs[] is sorted by name on write — bar before foo + expect(cfg.docs[0].name).toBe('bar') + expect(cfg.docs[1].name).toBe('foo') + expect(fs.existsSync(path.join(tmpDir, '.please', 'config.json'))).toBe(false) + }) + + it('runs exactly once — second invocation is a no-op', () => { + seedLegacy( + { docs: [{ source: 'npm', name: 'foo', version: '1.0.0' }] }, + { 'foo@1.0.0': '# foo' }, + ) + migrateLegacyWorkspace(tmpDir) + const firstReadme = fs.readFileSync( + path.join(tmpDir, '.ask', 'docs', 'foo@1.0.0', 'README.md'), + 'utf-8', + ) + // Tamper with the migrated file to verify second call doesn't re-migrate + fs.writeFileSync( + path.join(tmpDir, '.ask', 'docs', 'foo@1.0.0', 'README.md'), + 'tampered', + ) + migrateLegacyWorkspace(tmpDir) + const secondReadme = fs.readFileSync( + path.join(tmpDir, '.ask', 'docs', 'foo@1.0.0', 'README.md'), + 'utf-8', + ) + expect(secondReadme).toBe('tampered') + expect(firstReadme).toBe('# foo') + }) +}) diff --git a/packages/cli/test/schemas.test.ts b/packages/cli/test/schemas.test.ts new file mode 100644 index 0000000..f313514 --- /dev/null +++ b/packages/cli/test/schemas.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'bun:test' +import { + ConfigSchema, + LockEntrySchema, + LockSchema, + SourceConfigSchema, +} from '../src/schemas.js' + +describe('SourceConfigSchema', () => { + it('accepts a valid github source', () => { + const result = SourceConfigSchema.safeParse({ + source: 'github', + name: 'zod', + version: '3.22.4', + repo: 'colinhacks/zod', + tag: 'v3.22.4', + docsPath: 'docs', + }) + expect(result.success).toBe(true) + }) + + it('rejects a github source missing repo', () => { + const result = SourceConfigSchema.safeParse({ + source: 'github', + name: 'zod', + version: '3.22.4', + }) + expect(result.success).toBe(false) + }) + + it('accepts a valid npm source', () => { + const result = SourceConfigSchema.safeParse({ + source: 'npm', + name: 'hono', + version: '4.6.5', + }) + expect(result.success).toBe(true) + }) + + it('accepts a valid web source with at least one url', () => { + const result = SourceConfigSchema.safeParse({ + source: 'web', + name: 'somelib', + version: 'latest', + urls: ['https://example.com/docs'], + maxDepth: 2, + }) + expect(result.success).toBe(true) + }) + + it('rejects a web source with empty urls', () => { + const result = SourceConfigSchema.safeParse({ + source: 'web', + name: 'somelib', + version: 'latest', + urls: [], + }) + expect(result.success).toBe(false) + }) + + it('accepts a valid llms-txt source', () => { + const result = SourceConfigSchema.safeParse({ + source: 'llms-txt', + name: 'somelib', + version: 'latest', + url: 'https://example.com/llms.txt', + }) + expect(result.success).toBe(true) + }) + + it('rejects an unknown source type', () => { + const result = SourceConfigSchema.safeParse({ + source: 'ftp', + name: 'x', + version: '1.0.0', + }) + expect(result.success).toBe(false) + }) +}) + +describe('ConfigSchema', () => { + it('accepts an empty config', () => { + const result = ConfigSchema.safeParse({ + schemaVersion: 1, + docs: [], + }) + expect(result.success).toBe(true) + }) + + it('rejects a config without schemaVersion', () => { + const result = ConfigSchema.safeParse({ docs: [] }) + expect(result.success).toBe(false) + }) + + it('rejects a config with the wrong schemaVersion literal', () => { + const result = ConfigSchema.safeParse({ schemaVersion: 2, docs: [] }) + expect(result.success).toBe(false) + }) +}) + +describe('LockEntrySchema', () => { + const base = { + version: '3.22.4', + fetchedAt: '2026-04-07T06:00:00Z', + fileCount: 23, + contentHash: `sha256-${'a'.repeat(64)}`, + } + + it('accepts a valid github lock entry', () => { + const result = LockEntrySchema.safeParse({ + ...base, + source: 'github', + repo: 'colinhacks/zod', + ref: 'v3.22.4', + commit: 'a'.repeat(40), + }) + expect(result.success).toBe(true) + }) + + it('accepts a github lock entry without commit', () => { + const result = LockEntrySchema.safeParse({ + ...base, + source: 'github', + repo: 'colinhacks/zod', + ref: 'v3.22.4', + }) + expect(result.success).toBe(true) + }) + + it('rejects a github lock entry with malformed commit', () => { + const result = LockEntrySchema.safeParse({ + ...base, + source: 'github', + repo: 'colinhacks/zod', + ref: 'v3.22.4', + commit: 'not-a-sha', + }) + expect(result.success).toBe(false) + }) + + it('rejects a content hash with wrong format', () => { + const result = LockEntrySchema.safeParse({ + ...base, + contentHash: 'md5-abc', + source: 'npm', + tarball: 'https://registry.npmjs.org/hono/-/hono-4.6.5.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + }) + expect(result.success).toBe(false) + }) + + it('accepts a valid npm lock entry', () => { + const result = LockEntrySchema.safeParse({ + ...base, + source: 'npm', + tarball: 'https://registry.npmjs.org/hono/-/hono-4.6.5.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + }) + expect(result.success).toBe(true) + }) +}) + +describe('LockSchema', () => { + it('accepts an empty lock', () => { + const result = LockSchema.safeParse({ + lockfileVersion: 1, + generatedAt: '2026-04-07T06:00:00Z', + entries: {}, + }) + expect(result.success).toBe(true) + }) + + it('rejects a lock without lockfileVersion', () => { + const result = LockSchema.safeParse({ + generatedAt: '2026-04-07T06:00:00Z', + entries: {}, + }) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/cli/test/storage.test.ts b/packages/cli/test/storage.test.ts new file mode 100644 index 0000000..acf02c4 --- /dev/null +++ b/packages/cli/test/storage.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { getDocsDir, getLibraryDocsDir, saveDocs } from '../src/storage.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-storage-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('storage paths', () => { + it('getDocsDir points at .ask/docs (not .please/docs)', () => { + const dir = getDocsDir(tmpDir) + expect(dir).toBe(path.join(tmpDir, '.ask', 'docs')) + expect(dir.includes('.please')).toBe(false) + }) + + it('getLibraryDocsDir composes name@version under .ask/docs', () => { + const dir = getLibraryDocsDir(tmpDir, 'zod', '3.22.4') + expect(dir).toBe(path.join(tmpDir, '.ask', 'docs', 'zod@3.22.4')) + }) + + it('saveDocs writes files under .ask/docs/@', () => { + saveDocs(tmpDir, 'zod', '3.22.4', [ + { path: 'README.md', content: '# zod' }, + { path: 'guide/intro.md', content: 'intro' }, + ]) + const docsDir = path.join(tmpDir, '.ask', 'docs', 'zod@3.22.4') + expect(fs.existsSync(path.join(docsDir, 'README.md'))).toBe(true) + expect(fs.existsSync(path.join(docsDir, 'guide', 'intro.md'))).toBe(true) + expect(fs.existsSync(path.join(docsDir, 'INDEX.md'))).toBe(true) + }) + + it('saveDocs replaces an existing version directory', () => { + saveDocs(tmpDir, 'foo', '1.0.0', [{ path: 'old.md', content: 'old' }]) + saveDocs(tmpDir, 'foo', '1.0.0', [{ path: 'new.md', content: 'new' }]) + const docsDir = path.join(tmpDir, '.ask', 'docs', 'foo@1.0.0') + expect(fs.existsSync(path.join(docsDir, 'old.md'))).toBe(false) + expect(fs.existsSync(path.join(docsDir, 'new.md'))).toBe(true) + }) +})