Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# Finder (MacOS) folder config
.DS_Store
.packref
.packref/packages/
.packref/.packref-lock-*.tmp
.repos/effect
28 changes: 28 additions & 0 deletions .packref/packref-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"packages": [
{
"name": "@clack/prompts",
"registry": "npm",
"source": {
"directory": "packages/prompts",
"host": "github.com",
"type": "repository",
"url": "https://github.com/bombshell-dev/clack"
},
"tracking": "dependency",
"version": "1.7.0"
},
{
"name": "effect",
"registry": "npm",
"source": {
"directory": "packages/effect",
"host": "github.com",
"type": "repository",
"url": "https://github.com/Effect-TS/effect"
},
"tracking": "dependency",
"version": "4.0.0-beta.102"
}
]
}
5 changes: 5 additions & 0 deletions .plans/07-install-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ The lockfile is authoritative for `install`. Installing must not resolve project
versions, adopt manifest dependencies, or change lockfile contents; those behaviors belong to
`packref sync` and `packref add`.

## Status

Implemented. The lockfile is commit-ready, legacy ignore rules migrate safely, and `packref install`
materializes locked repository and tarball references without changing lockfile contents.

## User Experience

After cloning a project with a committed Packref lockfile:
Expand Down
6 changes: 3 additions & 3 deletions .plans/09-cli-polish-docs-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Prepare the v1 implementation for real use by tightening CLI output, documentati

## Implementation Steps

1. Review command output for `init`, `add`, `list`, `remove`, `prune`, `sync`, and `clean`.
1. Review command output for `init`, `add`, `install`, `list`, `remove`, `prune`, `sync`, and `clean`.
2. Convert typed errors into concise, actionable CLI messages.
3. Update README with install, init, add, list, remove, prune, sync, and clean examples.
3. Update README with package installation, init, add, committed-lockfile install, list, remove, prune, sync, and clean examples.
4. Confirm `bin` configuration points to the packaged CLI output.
5. Confirm package `files` include the expected build artifacts.
6. Run build and inspect output.
Expand All @@ -27,7 +27,7 @@ Prepare the v1 implementation for real use by tightening CLI output, documentati
## Acceptance Criteria

- README explains what Packref does and does not do.
- README documents `.packref/` and `~/.agents/packref/`.
- README documents the committed `.packref/packref-lock.json`, ignored `.packref/packages/`, and `~/.agents/packref/`.
- README explains repository vs tarball sources, the fallback rules, and the single-process (no locking) assumption.
- CLI help includes all commands.
- CLI errors are human-readable and actionable.
Expand Down
13 changes: 10 additions & 3 deletions .plans/implementation-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,14 @@ src/

references/
add.ts # Add a package reference
install.ts # Materialize references from the committed lockfile
remove.ts # Remove a package reference
sync.ts # Sync package references with project dependencies
prune.ts # Remove unused global store entries

commands/
init.ts # packref init
install.ts # packref install
add.ts # packref add [pkg]
remove.ts # packref remove [pkg]
list.ts # packref list
Expand Down Expand Up @@ -222,15 +224,17 @@ Goal: Wire everything together.
- `remove.ts` - Remove the project reference and lockfile entry
- `sync.ts` - Match Packref references to exact project dependency versions and remove dependency-tracked references for dependencies that no longer exist
- `prune.ts` - Find global store entries no project uses anymore and remove them after confirmation
- `install.ts` - Materialize all committed lockfile entries without rewriting the lockfile
2. `packref init` - Create project dir, empty lockfile, register in config
3. `packref add [pkg[@version]]` - With a package, parse CLI input and call `references/add.ts`; without one, offer unreferenced manifest dependencies in a multiselect and add the selections
4. `packref remove [pkg]` - With a package, remove matching references; without one, offer all referenced package versions in a multiselect and remove the selections
5. `packref list` - Read lockfile, print entries; for an empty lockfile, print a helpful "no packages currently installed" message
6. `packref prune` - Call `references/prune.ts` and report progress/errors
7. `packref sync` - Call `references/sync.ts` and report progress/errors
8. `packref clean` - Delete all project-local references and reset the project lockfile; with `--global` / `-g`, delete all global store entries while preserving project registrations and project-local `.packref/` directories
9. Wire root command with subcommands in `src/index.ts` using `Command.make(...).pipe(Command.withSubcommands([...]))`.
10. Write integration tests
8. `packref install` - Restore every missing project-local reference from the committed lockfile
9. `packref clean` - Delete all project-local references and reset the project lockfile; with `--global` / `-g`, delete all global store entries while preserving project registrations and project-local `.packref/` directories
10. Wire root command with subcommands in `src/index.ts` using `Command.make(...).pipe(Command.withSubcommands([...]))`.
11. Write integration tests

### Phase 6: Polish

Expand Down Expand Up @@ -263,6 +267,7 @@ All errors are modeled as tagged Effect errors using `Data.TaggedError`:
| `SnapshotFetchError` | `giget` snapshot fetch fails |
| `TarballFetchError` | Tarball download or extraction fails |
| `StoreCorruptedError` | Store entry exists but is invalid |
| `StoreSourceMismatchError` | Stored source differs from committed lockfile metadata |
| `NotInitializedError` | Running commands in a project without `packref init` |
| `LockfileParseError` | Lockfile JSON is malformed |
| `ManifestParseError` | Project manifest JSON is malformed |
Expand Down Expand Up @@ -318,6 +323,8 @@ Each error carries context (package name, version, path, etc.) for actionable CL
23. v1 assumes single-process use; there is no store/config locking.
24. `remove` and `sync` are drift-tolerant: a lockfile entry whose project directory is missing is still removed cleanly (with a warning), and directory deletion is best-effort.
25. Bare `packref add` offers manifest dependencies with no Packref reference through a multiselect prompt and adds the selected packages through the normal add pipeline.
26. `.packref/packref-lock.json` is committed; only `.packref/packages/` and atomic temporary lockfiles are ignored.
27. `packref install` restores locked manual and dependency-tracked references without consulting manifests or rewriting the lockfile. Compatible store entries are reused; source mismatches fail visibly.

## Open Questions

Expand Down
32 changes: 30 additions & 2 deletions .plans/packref-v1-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ Each project stores:
.packref/packref-lock.json
```

The lockfile lives inside `.packref/` for v1. Packref does not create a root-level lockfile.
The lockfile lives inside `.packref/` for v1 and is committed to version control. Packref does not
create a root-level lockfile. Materialized `.packref/packages/` source trees remain local and are
ignored.

Example:

Expand Down Expand Up @@ -249,12 +251,38 @@ Registers the project in the global config.

Also performs project integration (implemented, each step idempotent):

- appends `.packref/` to `.gitignore` (with confirmation prompt)
- adds `.packref/packages/` and `.packref/.packref-lock-*.tmp` to `.gitignore`, migrating exact
legacy `.packref` / `.packref/` rules (with confirmation prompt)
- adds `.packref` to the `exclude` list in `tsconfig.json` when one exists (JSONC-aware; warns on malformed files instead of crashing)
- writes a Packref usage section into `AGENTS.md` between `PACKREF:START`/`PACKREF:END` markers (with confirmation prompt; replaces the section on re-run)

---

## install

Materialize all source references already recorded in the committed lockfile.

```
packref install
```

Behavior:

1. require an initialized project and valid `.packref/packref-lock.json`
2. register the canonical project path globally
3. process lockfile entries in deterministic identity order
4. leave an existing project-local reference unchanged
5. otherwise reuse a global store entry only when its source metadata matches the lockfile
6. fetch a missing repository or tarball source directly from the locked metadata
7. create the project-local reference, respecting `source.directory`
8. leave the lockfile byte-for-byte unchanged

`packref install` restores Packref source references only. It does not install runtime dependencies
and does not replace a package-manager install. `install` follows the lockfile; `sync` may change
dependency-tracked lock entries to match the current project.

---

## add

Add a package reference.
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ This project was built with [`pastry`](https://github.com/adelrodriguez/pastry)
Packref provides local copies of dependency source code so you can inspect the exact implementation used by this project.

- Source references are stored in `.packref/packages/<registry>/<package>/<version>/` for unscoped packages and `.packref/packages/<registry>/<scope>/<package>/<version>/` for scoped packages — browse these directories to read dependency internals
- `.packref/` is developer-local and git-ignored; run `packref init` to set up, then `packref add [package]` to fetch references
- `.packref/packref-lock.json` is shared and should be committed; `.packref/packages/` is developer-local and git-ignored
- Run `packref install` after cloning when locked references are missing; install restores the lockfile exactly and does not install runtime dependencies
- Available commands:
- `packref add [package]` — select manifest dependencies or fetch a named package (e.g. `packref add react`, `packref add hono@4.2.0`, `packref add @effect/cli`)
- `packref remove [package]` — select or name package references to remove
- `packref sync` — update references to match current `package.json` dependency versions
- `packref install` — materialize every reference already recorded in the committed lockfile
- `packref sync` — update dependency-tracked lock entries to match current `package.json` dependency versions
- `packref list` — show all referenced packages
- `packref prune` — remove unused entries from the global store
- `packref clean` — remove all project-local references
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,42 @@
</div>

Made with [🥐 `pastry`](https://github.com/adelrodriguez/pastry)

## Quick start

Packref gives coding agents local, versioned copies of dependency source code. It installs source
references for inspection; it does not install your project's runtime dependencies.

```sh
packref init
packref add react
packref add hono@4.2.0
```

Commit `.packref/packref-lock.json` with the project. Packref ignores the generated
`.packref/packages/` trees and temporary lockfile writes.

After cloning a project with a Packref lockfile, restore its references with:

```sh
packref install
```

`install` follows the committed lockfile exactly and never changes it. Use `packref sync` when you
want dependency-tracked references and lock entries updated to match the versions installed by the
project's package manager.

## Storage

- `.packref/packref-lock.json` — committed identities, source metadata, and tracking modes.
- `.packref/packages/` — ignored project-local source trees used by agents.
- `~/.agents/packref/store/` — deduplicated global source snapshots.
- `~/.agents/packref/config.json` — registered project paths used by pruning.

Repository snapshots are preferred when package metadata provides a supported host and matching
version tag. Packref falls back to the published npm tarball when repository metadata, host support,
or a matching tag is unavailable.

Use `packref list` to inspect references, `packref remove` to remove selected references,
`packref clean` to clear the current project, `packref prune` to remove unused global entries, and
`packref clean --global` to wipe the global store.
66 changes: 60 additions & 6 deletions src/commands/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const runInitCommand = async (
const text = Buffer.from(data).toString("utf8")
stdout += text

if (!answeredIgnorePrompt && stdout.includes(".gitignore and tsconfig.json")) {
if (!answeredIgnorePrompt && stdout.includes("Ignore generated Packref references")) {
answeredIgnorePrompt = true
process.terminal?.write(ignoreInput)
}
Expand Down Expand Up @@ -117,7 +117,9 @@ describe("init", () => {
expect(config).toEqual({
projects: [canonicalProjectPath],
})
expect(await readText(join(projectPath, ".gitignore"))).toContain(".packref\n")
expect(await readText(join(projectPath, ".gitignore"))).toBe(
".packref/packages/\n.packref/.packref-lock-*.tmp\n"
)
})

it("does not duplicate project registrations", async () => {
Expand All @@ -135,7 +137,9 @@ describe("init", () => {
expect(config).toEqual({
projects: [canonicalProjectPath],
})
expect(countOccurrences(await readText(join(projectPath, ".gitignore")), ".packref")).toBe(1)
expect(await readText(join(projectPath, ".gitignore"))).toBe(
".packref/packages/\n.packref/.packref-lock-*.tmp\n"
)
})

it("does not duplicate project registrations through symlinked paths", async () => {
Expand Down Expand Up @@ -171,10 +175,12 @@ describe("init", () => {
const result = await runInitCommand(projectPath, homePath)

expect(result.exitCode).toBe(0)
expect(await readText(join(projectPath, ".gitignore"))).toBe("dist\n.packref\n")
expect(await readText(join(projectPath, ".gitignore"))).toBe(
"dist\n.packref/packages/\n.packref/.packref-lock-*.tmp"
)
})

it("does not duplicate existing gitignore folder entries", async () => {
it("migrates an exact legacy gitignore folder entry", async () => {
const projectPath = await makeTempDirectory()
const homePath = await makeTempDirectory()

Expand All @@ -183,7 +189,51 @@ describe("init", () => {
const result = await runInitCommand(projectPath, homePath)

expect(result.exitCode).toBe(0)
expect(await readText(join(projectPath, ".gitignore"))).toBe("dist\n.packref/\n")
expect(await readText(join(projectPath, ".gitignore"))).toBe(
"dist\n.packref/packages/\n.packref/.packref-lock-*.tmp\n"
)
})

it("migrates a legacy gitignore entry with trailing whitespace", async () => {
const projectPath = await makeTempDirectory()
const homePath = await makeTempDirectory()

await writeFile(join(projectPath, ".gitignore"), "dist\n.packref \n")

const result = await runInitCommand(projectPath, homePath)

expect(result.exitCode).toBe(0)
expect(await readText(join(projectPath, ".gitignore"))).toBe(
"dist\n.packref/packages/\n.packref/.packref-lock-*.tmp\n"
)
})

it("does not duplicate existing narrow gitignore entries", async () => {
const projectPath = await makeTempDirectory()
const homePath = await makeTempDirectory()
const existing = "dist\n.packref/packages/\n.packref/.packref-lock-*.tmp\n"

await writeFile(join(projectPath, ".gitignore"), existing)

const result = await runInitCommand(projectPath, homePath)

expect(result.exitCode).toBe(0)
expect(await readText(join(projectPath, ".gitignore"))).toBe(existing)
})

it("preserves CRLF, comments, unrelated rules, and a missing final newline", async () => {
const projectPath = await makeTempDirectory()
const homePath = await makeTempDirectory()
const existing = "# generated\r\ndist\r\n.packref\r\n.env"

await writeFile(join(projectPath, ".gitignore"), existing)

const result = await runInitCommand(projectPath, homePath)

expect(result.exitCode).toBe(0)
expect(await readText(join(projectPath, ".gitignore"))).toBe(
"# generated\r\ndist\r\n.packref/packages/\r\n.packref/.packref-lock-*.tmp\r\n.env"
)
})

it("leaves ignore files unchanged when declined", async () => {
Expand Down Expand Up @@ -419,6 +469,9 @@ describe("init", () => {
)
expect(agents).toContain("packref add [package]")
expect(agents).toContain("packref remove [package]")
expect(agents).toContain(".packref/packref-lock.json` is shared and should be committed")
expect(agents).toContain("packref install")
expect(agents).toContain("install restores the lockfile exactly")
expect(agents).toContain(packrefAgentsEndMarker)
expect(agents.endsWith("\n")).toBe(true)
})
Expand Down Expand Up @@ -551,6 +604,7 @@ describe("init", () => {

expect(result.exitCode).toBe(0)
expect(lockfile).toEqual(existingLockfile)
expect(result.stdout).toContain("Run `packref install` to materialize missing source trees")
})

it("fails for malformed lockfiles", async () => {
Expand Down
Loading