Skip to content

feat(cli): global ASK docs store at ~/.ask/#60

Merged
amondnet merged 14 commits into
mainfrom
amondnet/possible-headline
Apr 11, 2026
Merged

feat(cli): global ASK docs store at ~/.ask/#60
amondnet merged 14 commits into
mainfrom
amondnet/possible-headline

Conversation

@amondnet

@amondnet amondnet commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduce a global immutable docs store at ~/.ask/ (with ASK_HOME override) 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.tsresolveAskHome, per-kind path helpers, writeEntryAtomic, acquireEntryLock (with backoff), stampEntry/verifyEntry
  • packages/cli/src/store/github-bare.tswithBareClone for Cargo-style multi-ref efficiency; falls back to null when git is unavailable
  • packages/cli/src/store/cache.tscacheLs/cacheGc pure functions

Source adapter updates

All four source adapters (npm, github, web, llms-txt) now write into the global store before returning storePath in FetchResult:

  • <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-mode flag on ask install / ask add (and ask.json.storeMode):

  • copy (default): write full copy into .ask/docs/<pkg>@<v>/ — byte-identical to pre-change behavior
  • link: symlink to store, falls back to copy on EPERM/EACCES
  • ref: no project-local files; AGENTS.md points at store path directly

Precedence: CLI flag > ask.json.storeMode > 'copy'.

Cache commands

  • ask cache ls [--kind npm|github|web|llms-txt] — list entries with sizes
  • ask cache gc [--dry-run] — remove entries not referenced by any .ask/resolved.json under \$HOME (or ASK_GC_SCAN_ROOTS)

Schema

  • AskJsonSchema.storeMode?: 'copy' | 'link' | 'ref'
  • ResolvedEntrySchema.storePath?: string + materialization?: 'copy' | 'link' | 'ref'

Test plan

  • `bun run --cwd packages/schema test` — 60 pass
  • `bun run --cwd packages/cli test` — 270 pass (34 new tests across 5 new files)
  • `bun run --cwd packages/cli lint` — clean
  • `bun run --cwd packages/schema build && bun run --cwd packages/cli build` — green
  • Manual: `example/` project still installs cleanly with default copy mode (byte-identical output)
  • Manual: verify `link` mode on Linux/macOS creates symlinks
  • Manual: verify `ref` mode writes AGENTS.md pointing at store path
  • Manual: verify `ask cache ls` / `gc` on real store

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_HOME override) 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

    • Store-hit short-circuit: if a <pkg>@<version> exists in the store, materialize directly without fetching.
    • GitHub bare clone for faster multi-ref fetches, with tar.gz fallback when git is unavailable.
    • Cache commands: ask cache ls and ask cache gc --older-than <duration> (supports s/m/h/d).
  • Bug Fixes

    • Atomicity/correctness: added cpDirAtomic for safe directory writes; hashDir uses null-byte separators; URL normalization only lowercases scheme/host.
    • Sources: GitHub extraction now uses cpDirAtomic to avoid partial entries; Web store keys on all sorted URLs to prevent aliasing.
    • CLI/GC: validate --kind before use; cache gc ignores empty scan roots when $HOME is unset; surface readdirSync errors from store reads; only exclude root-level INDEX.md.
    • Security/guardrails: removed shell pipelines in GitHub tar path; strict owner/repo and ref validation; store/write paths are containment-checked. GC only trusts storePath values within ASK_HOME.

Written for commit 8830f74. Summary will update on new commits.

Verification Checklist

Functional:

  • Fresh ~/.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
  • Second project: same version → store hit, no network fetch
  • --store-mode=link: readlinkSync('.ask/docs/next@X') resolves under ~/.ask/
  • --store-mode=ref: AGENTS.md contains absolute <ASK_HOME> path, no .ask/docs/next@X/
  • ASK_HOME=/tmp/askh ... → all writes under /tmp/askh/
  • ask cache ls lists entries; ask cache gc --dry-run reports; ask cache gc removes
  • ask cache gc --older-than 30d only removes entries older than cutoff
  • GitHub source with two refs: one <ASK_HOME>/github/db/ bare clone, two checkouts

Non-regression:

  • bun run test across all packages green (342 passing)
  • bun run --cwd packages/cli lint clean
  • Existing example/ project installs and produces byte-identical AGENTS.md vs pre-change (default copy mode)
  • ask remove still tears down per-project materialization regardless of store mode

Docs:

  • CHANGELOG describes store, ASK_HOME, storeMode, cache ls|gc, github bare-clone
  • README "Global Store" section with tree diagram + Windows symlink note

amondnet added 10 commits April 10, 2026 17:26
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

codecov Bot commented Apr 11, 2026

Copy link
Copy Markdown

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 11, 2026

Copy link
Copy Markdown

Deploying ask-registry with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8830f74
Status:🚫  Build failed.

View logs

@amondnet amondnet marked this pull request as ready for review April 11, 2026 05:50
@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Apr 11, 2026
@dosubot dosubot Bot added the type:feature New feature or request label Apr 11, 2026
- Track moved to completed/
- PR #60 ready for review
- Product spec synced to product-specs/global-ask-docs-store-at-ask/

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/cli/src/store/cache.ts Outdated
Comment thread packages/cli/src/sources/web.ts Outdated
Comment thread packages/cli/src/sources/github.ts Outdated
Comment thread packages/cli/src/store/index.ts Outdated
Comment thread packages/cli/src/install.ts Outdated
Comment thread packages/cli/src/store/index.ts
Comment thread packages/cli/src/index.ts Outdated
Comment thread packages/cli/src/install.ts Outdated
Comment thread README.md Outdated
Comment thread packages/cli/CHANGELOG.md Outdated
@amondnet amondnet self-assigned this Apr 11, 2026
…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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@amondnet amondnet merged commit 51c77c8 into main Apr 11, 2026
2 of 5 checks passed
This was referenced Apr 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files. type:feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

track: global-docs-store-20260410

1 participant