feat(cli): global ASK docs store at ~/.ask/#60
Conversation
Add `storeMode?: 'copy' | 'link' | 'ref'` to AskJsonSchema for configuring how store entries are materialized into projects. Add `storePath?: string` and `materialization?: 'copy' | 'link' | 'ref'` to ResolvedEntrySchema for tracking store state per resolved entry. Both fields are optional and backward-compatible — existing ask.json and resolved.json files parse unchanged. Ref: T001, T002, T003
Introduce `packages/cli/src/store/index.ts` with: - `resolveAskHome()`: ASK_HOME env > ~/.ask/ default - Per-kind path helpers (npm, github, web, llms-txt) - `writeEntryAtomic`: temp dir + rename for crash safety - `acquireEntryLock`: exclusive .lock file with backoff - `stampEntry` / `verifyEntry`: content hash validation Ref: T004, T005
Introduce `withBareClone(askHome, owner, repo, ref)` that maintains a shared bare clone at `<ASK_HOME>/github/db/<owner>__<repo>.git/` and extracts per-ref checkouts via `git archive | tar`. Falls back to `null` when git is unavailable so callers can use the existing tar.gz download path. Ref: T006, T007
All four source adapters (npm, github, web, llms-txt) now write fetched docs into the global store at ASK_HOME before returning. FetchResult gains an optional `storePath` field pointing to the finalized store entry. - NpmSource: writes to <ASK_HOME>/npm/<pkg>@<version>/ - GithubSource: tries bare clone first, falls back to tar.gz; writes to <ASK_HOME>/github/checkouts/<owner>__<repo>/<ref>/ - WebSource: writes to <ASK_HOME>/web/<sha256-of-url>/ - LlmsTxtSource: writes to <ASK_HOME>/llms-txt/<sha256>@<version>/ All writes use acquireEntryLock + writeEntryAtomic for safety. Ref: T008, T009, T010, T011, T012
- `saveDocs` now accepts `storeMode` and `storePath` options: - copy (default): write files into project-local .ask/docs/ - link: symlink to store, falls back to copy on EPERM/EACCES - ref: no project-local materialization, AGENTS.md points at store - `runInstall` resolves storeMode precedence (CLI > ask.json > copy) and threads it through installOne → saveDocs - `generateAgentsMd` reads materialization + storePath from resolved entries for ref mode path resolution - CLI gains `--store-mode=<copy|link|ref>` on install and add commands Ref: T013, T014, T015, T016
Introduce `ask cache ls [--kind npm|github|web|llms-txt]` to list store entries with sizes, and `ask cache gc [--dry-run]` to remove entries not referenced by any .ask/resolved.json on the machine. GC scans $HOME (or ASK_GC_SCAN_ROOTS) for resolved.json files to build the referenced-keys set before deleting. Ref: T017, T018, T019
Cover copy/link/ref materialization modes, store hit detection, symlink creation on POSIX, ref mode skip of project-local files, and concurrent write safety. Ref: T020, T021, T022, T023, T025
Add "Global Store" section to README with layout diagram, storeMode table, cache commands, and Windows symlink note. Add unreleased CHANGELOG entries for store, cache commands, schema changes, and ASK_HOME. Fix ESLint issues: module-scope regex, process import, unused var. Ref: T026, T027, T028
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Address 7 Critical + 12 Important issues from PR #60 code review. ## Security (Critical) - Replace shell pipeline in GithubSource.fetchFromTarGz with Node fetch + spawnSync (no shell), eliminating command injection via template-string interpolation of repo/ref. - Add assertContained() guard in store path helpers (npmStorePath/githubDbPath/githubCheckoutPath) to reject path-traversal attempts via `..` segments. - writeEntryAtomic validates each file.path stays inside tmpDir before writing, blocking malicious archive escape. - Validate repo/ref against RE_SAFE_REPO/RE_SAFE_REF in fetchFromTarGz as defense-in-depth. - cacheGc scanner validates storePath from resolved.json stays inside askHome before trusting it. Symlink directories are naturally excluded by isDirectory() check. ## Store integrity (Critical) - GithubSource.fetchFromTarGz now copies the FULL extracted repo root to the store (not just flat docs files), so store-hit re-parse on git-less machines works correctly. - writeEntryAtomic uses rename-to-backup pattern to minimize the race window where targetDir is briefly absent. - acquireEntryLock no longer deletes live locks on timeout — throws a descriptive error instead. Prevents two concurrent writers entering the same store entry when the holder exceeds the 60s deadline. ## Spec compliance - SC-2: Add global store-hit short-circuit in installOne — when a previous install on this machine populated the store for (pkg, version), materialize from store without calling the source adapter. - SC-7: Implement `ask cache gc --older-than <duration>` with parseDuration() helper supporting s/m/h/d suffixes. ## Error handling - withBareClone: rethrow ENOSPC/EACCES/EPERM as fatal (tar.gz fallback won't help for filesystem errors). - tryLocalRead: log package.json parse failures at debug level instead of silent fallback. - storage.ts link-mode: clean up partial symlink state before falling back to copy; include error context in rethrown errors. ## Tests - Rewrite github-bare.test.ts to actually call withBareClone against a local file-path remote. Adds BareCloneOptions with injectable remoteUrl for testability. 5 tests covering fresh clone, ref reuse, existing checkout skip, missing ref, invalid remote. - Add agents-ref-mode.test.ts: 4 tests for generateAgentsMd ref-mode path resolution (SC-4 coverage). - Add install-e2e.test.ts: 4 E2E tests for runInstall store-hit short-circuit, ask.json.storeMode, CLI precedence, default mode. - Extend install-store.test.ts with EPERM mock test and acquireEntryLock contention test. - Fix writeEntryAtomic test to exercise actual failure branch. ## Results - 342 tests pass (schema 60 + cli 282), up from 330 - lint clean, build green - no regressions in existing functionality Ref: PR #60 review
- Track moved to completed/ - PR #60 ready for review - Product spec synced to product-specs/global-ask-docs-store-at-ask/
There was a problem hiding this comment.
0 issues found across 6 files (changes from recent commits).
Requires human review: High-impact architectural change introducing a global file store, locking mechanisms, and a garbage collector. Contains documented subtle atomicity risks requiring human verification.
There was a problem hiding this comment.
10 issues found across 30 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="README.md">
<violation number="1" location="README.md:228">
P3: The `storeMode=link` docs are inaccurate: fallback is documented as `EPERM` only, but code falls back on both `EPERM` and `EACCES`.</violation>
</file>
<file name="packages/cli/src/store/cache.ts">
<violation number="1" location="packages/cli/src/store/cache.ts:126">
P1: `cacheGc` defaults to `process.env.HOME` only; when `HOME` is unset, reference scanning is skipped and valid cache entries can be wrongly garbage-collected.</violation>
</file>
<file name="packages/cli/src/sources/web.ts">
<violation number="1" location="packages/cli/src/sources/web.ts:52">
P1: Store keying only on `opts.urls[0]` can alias different multi-URL web configs to the same cache entry, causing incorrect docs to be materialized in `link`/`ref` modes.</violation>
</file>
<file name="packages/cli/src/sources/github.ts">
<violation number="1" location="packages/cli/src/sources/github.ts:134">
P1: Non-atomic store write: if the process crashes between `rmSync` and `cpSync` (or before `stampEntry`), the entry is left corrupt but will pass the `fs.existsSync` store-hit check at the top of `fetch()`, silently serving incomplete docs. Copy to a temp directory first and rename atomically, mirroring the pattern in `writeEntryAtomic`.</violation>
</file>
<file name="packages/cli/CHANGELOG.md">
<violation number="1" location="packages/cli/CHANGELOG.md:11">
P3: The changelog documents `link` mode fallback for `EPERM` only, but this release’s behavior includes `EACCES` fallback too. Update the line to match actual behavior.</violation>
</file>
<file name="packages/cli/src/store/index.ts">
<violation number="1" location="packages/cli/src/store/index.ts:83">
P1: `normalizeUrl` lowercases the entire URL including the path, which is case-sensitive per RFC 3986. This will cause distinct URLs (differing only in path casing) to collide in the store. Only the scheme and host should be lowercased.</violation>
<violation number="2" location="packages/cli/src/store/index.ts:253">
P2: `hashDir` concatenates file names and file contents into the hash without any separator. This creates ambiguity: file `"ab"` with content `"cd"` hashes identically to file `"a"` with content `"bcd"`. Add a null-byte or length prefix between fields to ensure unique hash inputs.</violation>
</file>
<file name="packages/cli/src/index.ts">
<violation number="1" location="packages/cli/src/index.ts:264">
P2: Validate `--kind` before passing it to `cacheLs`; the current cast allows invalid values and can make `cache ls` traverse paths outside `ASK_HOME`.</violation>
</file>
<file name="packages/cli/src/install.ts">
<violation number="1" location="packages/cli/src/install.ts:469">
P1: Do not silently skip unreadable store files; it can produce partial installs while reporting success.</violation>
<violation number="2" location="packages/cli/src/install.ts:474">
P2: `readFilesFromStore` drops all `INDEX.md` files, including nested real docs. Only the root generated index should be excluded.</violation>
</file>
Architecture diagram
sequenceDiagram
participant User as User/CLI
participant Runner as Install Runner
participant Adapter as Source Adapter (npm/github/web)
participant Store as Global Store Logic (~/.ask/)
participant GFS as Global FileSystem
participant LFS as Local Project (.ask/)
participant Remote as External API/Git
Note over User,Remote: NEW: Global Store Installation Flow
User->>Runner: ask install / add
Runner->>Store: resolveAskHome() (ASK_HOME or ~/.ask/)
rect rgb(240, 240, 240)
Note right of Runner: Store Hit Check
Runner->>GFS: NEW: Check if entry exists for <pkg>@<version>
alt NEW: Entry Found (Store Hit)
GFS-->>Runner: Existing Path
else NEW: Entry Missing (Store Miss)
Runner->>Adapter: fetch(spec)
alt NEW: GitHub Source (Optimized)
Adapter->>Store: withBareClone()
Store->>GFS: Check shared bare repo in /github/db/
alt Bare Repo Missing
Store->>Remote: git clone --bare
else Bare Repo Exists
Store->>Remote: git fetch origin <ref>
end
Store->>GFS: git archive to /github/checkouts/
else npm/Web/llms-txt
Adapter->>Remote: Download tarball/content
end
Adapter->>Store: NEW: acquireEntryLock(entryPath)
Store->>GFS: Create <entry>.lock (with backoff)
Adapter->>Store: NEW: writeEntryAtomic()
Store->>GFS: Write to .tmp dir -> Atomic Rename
Adapter->>Store: NEW: stampEntry()
Store->>GFS: Write .ask-hash (integrity)
Adapter->>Store: release lock
Store->>GFS: unlink <entry>.lock
Adapter-->>Runner: FetchResult (includes storePath)
end
end
rect rgb(230, 245, 255)
Note right of Runner: Materialization Mode (CLI flag or ask.json)
alt NEW: storeMode == 'link'
Runner->>LFS: CHANGED: symlink .ask/docs/ -> ~/.ask/entry
Note over LFS: Fallback to copy on EPERM
else NEW: storeMode == 'ref'
Runner->>Runner: Skip local doc creation
else storeMode == 'copy' (Default)
Runner->>LFS: Recursive copy from Global Store
end
end
Runner->>LFS: CHANGED: Update resolved.json (storePath + materialization)
Runner->>LFS: CHANGED: generateAgentsMd() (point to Local or Global path)
Runner-->>User: Done
Note over User,GFS: Cache Management (Separate Flow)
User->>Runner: ask cache ls
Runner->>Store: cacheLs()
Store->>GFS: Walk kind directories (npm, github, etc)
Runner-->>User: Display entries and sizes
User->>Runner: ask cache gc
Runner->>LFS: Scan roots for .ask/resolved.json
Runner->>Store: cacheGc(referencedPaths)
Store->>GFS: NEW: Remove unreferenced entries (olderThan filter)
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
…y fixes - cache.ts: filter out empty scanRoots when HOME is unset to prevent wrong GC - store/index.ts: normalizeUrl only lowercases scheme+host (path is case-sensitive per RFC 3986) - store/index.ts: hashDir adds null-byte separators between file name and content to prevent ambiguous hash inputs - store/index.ts: add cpDirAtomic helper for atomic directory copy (temp + rename) - sources/github.ts: use cpDirAtomic to prevent corrupt store entries on crash - sources/web.ts: key store entry on all sorted URLs (not just urls[0]) to prevent multi-URL aliasing - install.ts: propagate readdirSync errors in readFilesFromStore instead of silently returning - install.ts: only exclude root-level INDEX.md, not nested ones - index.ts: validate --kind value before use to prevent traversal outside ASK_HOME - README.md, CHANGELOG.md: document link mode fallback for both EPERM and EACCES
There was a problem hiding this comment.
0 issues found across 8 files (changes from recent commits).
Requires human review: Large architectural change introducing a global store, new CLI commands, and complex materialization logic (linking/copying) with known atomicity nuances requiring human review.
Summary
Introduce a global immutable docs store at
~/.ask/(withASK_HOMEoverride) so identical<pkg>@<version>entries are fetched once per machine and reused across projects — the same pattern as Cargo, Go modules, pnpm, and Deno.Closes #55
Changes
Store infrastructure
packages/cli/src/store/index.ts—resolveAskHome, per-kind path helpers,writeEntryAtomic,acquireEntryLock(with backoff),stampEntry/verifyEntrypackages/cli/src/store/github-bare.ts—withBareClonefor Cargo-style multi-ref efficiency; falls back tonullwhen git is unavailablepackages/cli/src/store/cache.ts—cacheLs/cacheGcpure functionsSource adapter updates
All four source adapters (npm, github, web, llms-txt) now write into the global store before returning
storePathinFetchResult:<ASK_HOME>/npm/<pkg>@<version>/<ASK_HOME>/github/db/<owner>__<repo>.git/+<ASK_HOME>/github/checkouts/<owner>__<repo>/<ref>/<ASK_HOME>/web/<sha256-of-normalized-url>/<ASK_HOME>/llms-txt/<sha256>@<version>/Materialization modes
New
--store-modeflag onask install/ask add(andask.json.storeMode):copy(default): write full copy into.ask/docs/<pkg>@<v>/— byte-identical to pre-change behaviorlink: symlink to store, falls back to copy onEPERM/EACCESref: no project-local files; AGENTS.md points at store path directlyPrecedence: CLI flag >
ask.json.storeMode>'copy'.Cache commands
ask cache ls [--kind npm|github|web|llms-txt]— list entries with sizesask cache gc [--dry-run]— remove entries not referenced by any.ask/resolved.jsonunder\$HOME(orASK_GC_SCAN_ROOTS)Schema
AskJsonSchema.storeMode?: 'copy' | 'link' | 'ref'ResolvedEntrySchema.storePath?: string+materialization?: 'copy' | 'link' | 'ref'Test plan
Backward compatibility
Default `storeMode: copy` keeps output byte-identical at the project level. Existing `ask.json` files parse unchanged. The store is additive — no forced migration.
Summary by cubic
Adds a global, immutable docs store at
~/.ask/(ASK_HOMEoverride) with copy/link/ref modes and cache commands. Speeds installs by reusing<pkg>@<version>across projects and short‑circuiting when a store entry exists. Implements #55.New Features
<pkg>@<version>exists in the store, materialize directly without fetching.gitis unavailable.ask cache lsandask cache gc --older-than <duration>(supportss/m/h/d).Bug Fixes
cpDirAtomicfor safe directory writes;hashDiruses null-byte separators; URL normalization only lowercases scheme/host.cpDirAtomicto avoid partial entries; Web store keys on all sorted URLs to prevent aliasing.--kindbefore use;cache gcignores empty scan roots when$HOMEis unset; surfacereaddirSyncerrors from store reads; only exclude root-levelINDEX.md.owner/repoandrefvalidation; store/write paths are containment-checked. GC only trustsstorePathvalues withinASK_HOME.Written for commit 8830f74. Summary will update on new commits.
Verification Checklist
Functional:
~/.ask/:bun run --cwd packages/cli build && cd example && node ../packages/cli/dist/index.js install→~/.ask/npm/next@X/materialized,.ask/docs/next@X/copied, bytes match--store-mode=link:readlinkSync('.ask/docs/next@X')resolves under~/.ask/--store-mode=ref:AGENTS.mdcontains absolute<ASK_HOME>path, no.ask/docs/next@X/ASK_HOME=/tmp/askh ...→ all writes under/tmp/askh/ask cache lslists entries;ask cache gc --dry-runreports;ask cache gcremovesask cache gc --older-than 30donly removes entries older than cutoff<ASK_HOME>/github/db/bare clone, two checkoutsNon-regression:
bun run testacross all packages green (342 passing)bun run --cwd packages/cli lintcleanexample/project installs and produces byte-identicalAGENTS.mdvs pre-change (defaultcopymode)ask removestill tears down per-project materialization regardless of store modeDocs:
ASK_HOME,storeMode,cache ls|gc, github bare-clone