diff --git a/.gitignore b/.gitignore index 3af6226..e5aa98f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.packref/packref-lock.json b/.packref/packref-lock.json new file mode 100644 index 0000000..929041b --- /dev/null +++ b/.packref/packref-lock.json @@ -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" + } + ] +} diff --git a/.plans/07-install-command.md b/.plans/07-install-command.md index 4115c75..6f1998a 100644 --- a/.plans/07-install-command.md +++ b/.plans/07-install-command.md @@ -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: diff --git a/.plans/09-cli-polish-docs-release.md b/.plans/09-cli-polish-docs-release.md index 47caae4..786a3bc 100644 --- a/.plans/09-cli-polish-docs-release.md +++ b/.plans/09-cli-polish-docs-release.md @@ -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. @@ -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. diff --git a/.plans/implementation-strategy.md b/.plans/implementation-strategy.md index c00233e..d652591 100644 --- a/.plans/implementation-strategy.md +++ b/.plans/implementation-strategy.md @@ -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 @@ -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 @@ -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 | @@ -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 diff --git a/.plans/packref-v1-spec.md b/.plans/packref-v1-spec.md index 8fd7d2d..d862ad2 100644 --- a/.plans/packref-v1-spec.md +++ b/.plans/packref-v1-spec.md @@ -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: @@ -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. diff --git a/AGENTS.md b/AGENTS.md index a8b7b32..2cdd589 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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////` for unscoped packages and `.packref/packages/////` 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 diff --git a/README.md b/README.md index c759933..a0e4fbe 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,42 @@ 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. diff --git a/src/commands/__tests__/init.test.ts b/src/commands/__tests__/init.test.ts index f8ccdb5..a0f8d61 100644 --- a/src/commands/__tests__/init.test.ts +++ b/src/commands/__tests__/init.test.ts @@ -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) } @@ -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 () => { @@ -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 () => { @@ -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() @@ -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 () => { @@ -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) }) @@ -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 () => { diff --git a/src/commands/__tests__/install.test.ts b/src/commands/__tests__/install.test.ts new file mode 100644 index 0000000..4a5e851 --- /dev/null +++ b/src/commands/__tests__/install.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { createTarGzip } from "nanotar" +import type { PackageEntry } from "#lib/workspace/lockfile.ts" +import { + initializeProject, + makeCommandTestContext, + materializeReference, + repositoryEntry, +} from "#commands/__tests__/helpers.ts" + +const context = makeCommandTestContext("packref-install-command-test-") + +const tarballEntry = (name: string, version: string, url: string) => + ({ + name, + registry: "npm", + source: { type: "tarball", url }, + tracking: "manual", + version, + }) satisfies PackageEntry + +const materializeStoreEntry = async (homePath: string, entry: PackageEntry) => { + const identitySegments = ["packages", entry.registry, ...entry.name.split("/"), entry.version] + const entryPath = join(homePath, ".agents", "packref", "store", ...identitySegments) + const metadataPath = join( + homePath, + ".agents", + "packref", + "store", + ".metadata", + ...identitySegments.slice(0, -1), + `${entry.version}.json` + ) + await mkdir(entryPath, { recursive: true }) + await mkdir(join(metadataPath, ".."), { recursive: true }) + await writeFile(join(entryPath, "SOURCE.md"), "stored source") + await writeFile(metadataPath, JSON.stringify({ source: entry.source })) +} + +afterEach(context.cleanup) + +describe("install command", () => { + it("reports an empty committed lockfile as a no-op", async () => { + const projectPath = await context.makeTempDirectory() + const homePath = await context.makeTempDirectory() + await initializeProject(projectPath, []) + + const result = await context.runCli({ + args: ["install"], + homePath, + projectPath, + }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain("No locked references to install") + }) + + it("reports references that are already materialized", async () => { + const projectPath = await context.makeTempDirectory() + const homePath = await context.makeTempDirectory() + const entry = repositoryEntry("example", "1.0.0") + await initializeProject(projectPath, [entry]) + await materializeReference(projectPath, entry) + + const result = await context.runCli({ + args: ["install"], + homePath, + projectPath, + }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain("1 reference is already installed") + expect(result.output).toContain("All locked references are already installed") + expect(result.output).not.toContain("Installed 1 locked reference") + }) + + it("reports references fetched from locked tarball metadata", async () => { + const projectPath = await context.makeTempDirectory() + const homePath = await context.makeTempDirectory() + const archive = await createTarGzip([{ data: "source", name: "package/SOURCE.md" }]) + const server = Bun.serve({ + fetch: () => new Response(archive), + port: 0, + }) + + try { + const entry = tarballEntry("example", "1.0.0", new URL("example.tgz", server.url).href) + await initializeProject(projectPath, [entry]) + + const result = await context.runCli({ + args: ["install"], + homePath, + projectPath, + }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain("Fetched 1 reference") + } finally { + await server.stop(true) + } + }) + + it("reports references reused from the global store", async () => { + const projectPath = await context.makeTempDirectory() + const homePath = await context.makeTempDirectory() + const entry = tarballEntry( + "example", + "1.0.0", + "https://registry.npmjs.org/example/-/example-1.0.0.tgz" + ) + await initializeProject(projectPath, [entry]) + await materializeStoreEntry(homePath, entry) + + const result = await context.runCli({ + args: ["install"], + homePath, + projectPath, + }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain("Reused 1 global store entry") + }) + + it("distinguishes Packref references from runtime dependencies in help", async () => { + const projectPath = await context.makeTempDirectory() + const homePath = await context.makeTempDirectory() + + const installHelp = await context.runCli({ + args: ["install", "--help"], + homePath, + projectPath, + }) + const rootHelp = await context.runCli({ + args: ["--help"], + homePath, + projectPath, + }) + + expect(installHelp.exitCode).toBe(0) + expect(installHelp.output).toContain("not project dependencies") + expect(rootHelp.output).toContain("install") + }) +}) diff --git a/src/commands/init.ts b/src/commands/init.ts index 95bcb4d..d48eb4f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -29,7 +29,7 @@ export default Command.make("init").pipe( const shouldAddIgnoreEntries = yield* prompter.confirm({ initialValue: true, - message: "Add `.packref` to .gitignore and tsconfig.json exclude?", + message: "Ignore generated Packref references and exclude `.packref` from TypeScript?", }) const shouldAddAgentsGuidance = yield* prompter.confirm({ @@ -43,7 +43,7 @@ export default Command.make("init").pipe( success: "Project directory is ready", }) - yield* prompter.withSpinner(() => initializeLockfile(projectPath), { + const lockfile = yield* prompter.withSpinner(() => initializeLockfile(projectPath), { failure: "Failed to create the packref-lock.json", start: "Creating the packref-lock.json...", success: "Created the packref-lock.json", @@ -98,6 +98,12 @@ export default Command.make("init").pipe( } } + if (lockfile.packages.length > 0) { + yield* prompter.log.info( + `Found ${lockfile.packages.length} locked reference${lockfile.packages.length === 1 ? "" : "s"}. Run \`packref install\` to materialize missing source trees.` + ) + } + yield* prompter.log.success(`Initialized packref in ${projectPath}`) yield* prompter.outro("🎉 You're ready to start using Packref!") }).pipe( diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 0000000..c2c7dbc --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect" +import * as Command from "effect/unstable/cli/Command" +import { + installPackageReferences, + type InstallPackageReferencesResult, +} from "#lib/references/install.ts" +import { Prompter } from "#lib/services/prompter.ts" +import { printTitle } from "#lib/shared/terminal.ts" + +const reportInstallResult = Effect.fn("reportInstallResult")(function* ( + result: InstallPackageReferencesResult +) { + const prompter = yield* Prompter + + if (result.fetched.length > 0) { + yield* prompter.log.success( + `Fetched ${result.fetched.length} reference${result.fetched.length === 1 ? "" : "s"}` + ) + } + + if (result.reused.length > 0) { + yield* prompter.log.success( + `Reused ${result.reused.length} global store entr${result.reused.length === 1 ? "y" : "ies"}` + ) + } + + if (result.alreadyInstalled.length > 0) { + yield* prompter.log.info( + `${result.alreadyInstalled.length} reference${result.alreadyInstalled.length === 1 ? " is" : "s are"} already installed` + ) + } +}) + +export default Command.make("install").pipe( + Command.withDescription( + "Install Packref source references from packref-lock.json (not project dependencies)" + ), + Command.withHandler(() => + Effect.gen(function* () { + const prompter = yield* Prompter + + yield* printTitle() + yield* prompter.intro("📥 packref install") + + const result = yield* prompter.withSpinner(() => installPackageReferences(), { + failure: "Failed to install Packref references", + start: "Installing references from packref-lock.json...", + success: "Finished installing Packref references", + }) + const installed = result.fetched.length + result.reused.length + + yield* reportInstallResult(result) + yield* prompter.outro( + installed > 0 + ? `Installed ${installed} locked reference${installed === 1 ? "" : "s"}` + : result.alreadyInstalled.length > 0 + ? "All locked references are already installed" + : "No locked references to install" + ) + }) + ) +) diff --git a/src/index.ts b/src/index.ts index 18ec56d..c852b19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import * as Command from "effect/unstable/cli/Command" import add from "#commands/add.ts" import clean from "#commands/clean.ts" import init from "#commands/init.ts" +import install from "#commands/install.ts" import list from "#commands/list.ts" import prune from "#commands/prune.ts" import remove from "#commands/remove.ts" @@ -24,7 +25,7 @@ import { getPackageVersion } from "#version.macro.ts" with { type: "macro" } const main = Command.make("packref").pipe( Command.withDescription("Local, versioned package references for your agents"), - Command.withSubcommands([add, clean, init, list, prune, remove, sync]) + Command.withSubcommands([add, clean, init, install, list, prune, remove, sync]) ) const version = await getPackageVersion() diff --git a/src/lib/core/errors.ts b/src/lib/core/errors.ts index e0b7002..4df88fd 100644 --- a/src/lib/core/errors.ts +++ b/src/lib/core/errors.ts @@ -182,6 +182,16 @@ export class StoreCorruptedError extends Data.TaggedError("StoreCorruptedError") } } +export class StoreSourceMismatchError extends Data.TaggedError("StoreSourceMismatchError")<{ + name: string + registry: string + version: string +}> { + override get message() { + return `Global store entry for \`${this.registry}:${this.name}@${this.version}\` does not match the source recorded in packref-lock.json. Run \`packref clean --global\` and retry.` + } +} + export class ReflinkError extends Data.TaggedError("ReflinkError")<{ cause: unknown source: string diff --git a/src/lib/core/source.ts b/src/lib/core/source.ts index 759773d..15fae5e 100644 --- a/src/lib/core/source.ts +++ b/src/lib/core/source.ts @@ -21,6 +21,7 @@ export type TarballSource = typeof TarballSourceSchema.Type export const PackageSourceSchema = Schema.Union([RepositorySourceSchema, TarballSourceSchema]) export type PackageSource = typeof PackageSourceSchema.Type +export const packageSourceEquivalence = Schema.toEquivalence(PackageSourceSchema) export interface NormalizedRepositorySource extends RepositorySource { readonly fetchSource: string | undefined diff --git a/src/lib/references/__tests__/install.test.ts b/src/lib/references/__tests__/install.test.ts new file mode 100644 index 0000000..ed566b0 --- /dev/null +++ b/src/lib/references/__tests__/install.test.ts @@ -0,0 +1,411 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" +import { createTarGzip } from "nanotar" +import type { PackageEntry } from "#lib/workspace/lockfile.ts" +import { exists, initializeProject } from "#commands/__tests__/helpers.ts" +import { StoreSourceMismatchError, TarballFetchError } from "#lib/core/errors.ts" +import { installPackageReferences } from "#lib/references/install.ts" +import { CommandRunner } from "#lib/services/command-runner.ts" +import { PackrefHome } from "#lib/services/packref-home.ts" +import { Reflinker } from "#lib/services/reflinker.ts" +import { RepositoryDownloader } from "#lib/sources/repository/fetch.ts" + +const temporaryPaths: string[] = [] + +const makeTempDirectory = async () => { + const directoryPath = await mkdtemp(join(tmpdir(), "packref-install-test-")) + temporaryPaths.push(directoryPath) + return directoryPath +} + +const tarballEntry = ( + name: string, + version: string, + tracking: PackageEntry["tracking"] = "manual", + url = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz` +) => + ({ + name, + registry: "npm", + source: { type: "tarball", url }, + tracking, + version, + }) satisfies PackageEntry + +const repositoryEntry = ( + name: string, + version: string, + directory?: string, + tracking: PackageEntry["tracking"] = "manual" +) => + ({ + name, + registry: "npm", + source: { + ...(directory === undefined ? {} : { directory }), + host: "github.com", + type: "repository", + url: `https://github.com/example/${name.replace("@scope/", "")}`, + }, + tracking, + version, + }) satisfies PackageEntry + +const getIdentitySegments = (entry: PackageEntry) => [ + "packages", + entry.registry, + ...entry.name.split("/"), + entry.version, +] + +const getReferencePath = (projectPath: string, entry: PackageEntry) => + join(projectPath, ".packref", ...getIdentitySegments(entry)) + +const materializeStoredEntry = async (home: string, entry: PackageEntry) => { + const segments = getIdentitySegments(entry) + const entryPath = join(home, ".agents", "packref", "store", ...segments) + const metadataPath = join( + home, + ".agents", + "packref", + "store", + ".metadata", + ...segments.slice(0, -1), + `${entry.version}.json` + ) + await mkdir(entryPath, { recursive: true }) + await mkdir(join(metadataPath, ".."), { recursive: true }) + await writeFile(join(entryPath, "SOURCE.md"), `${entry.name}@${entry.version}`) + await writeFile(metadataPath, `${JSON.stringify({ source: entry.source }, null, 2)}\n`) + return entryPath +} + +interface TestControls { + readonly failedTarballUrl?: string + repositoryDownloads: number + tarballDownloads: number +} + +const makeTestLayer = (home: string, controls: TestControls) => + Layer.mergeAll( + NodeServices.layer, + PackrefHome.at(home), + Reflinker.layer, + Layer.succeed(CommandRunner)({ + run: () => + Effect.succeed({ + exitCode: 0, + stderr: "", + stdout: "abc123\trefs/tags/v1.0.0\nabc456\trefs/tags/v2.0.0", + }), + }), + Layer.succeed(RepositoryDownloader)({ + download: (_source, _ref, destination) => { + const nextDownloadCount = controls.repositoryDownloads + 1 + Object.assign(controls, { repositoryDownloads: nextDownloadCount }) + return Effect.promise(async () => { + await mkdir(join(destination, "packages", "example"), { recursive: true }) + await writeFile(join(destination, "README.md"), "repository root") + await writeFile(join(destination, "packages", "example", "index.ts"), "package source") + }) + }, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => { + const nextDownloadCount = controls.tarballDownloads + 1 + Object.assign(controls, { tarballDownloads: nextDownloadCount }) + + if (url.href === controls.failedTarballUrl) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 500 })) + ) + } + + return Effect.promise(() => + createTarGzip([ + { + data: `source from ${request.url}`, + name: "package/SOURCE.md", + }, + ]) + ).pipe( + Effect.map((archive) => + HttpClientResponse.fromWeb(request, new Response(archive, { status: 200 })) + ) + ) + }) + ) + ) + +const runInstall = (projectPath: string, home: string, controls?: TestControls) => { + const resolvedControls = controls ?? { + repositoryDownloads: 0, + tarballDownloads: 0, + } + + return Effect.runPromise( + installPackageReferences({ projectPath }).pipe( + Effect.provide(makeTestLayer(home, resolvedControls)) + ) + ) +} + +afterEach(async () => { + await Promise.all( + temporaryPaths.splice(0).map((directoryPath) => + rm(directoryPath, { + force: true, + recursive: true, + }) + ) + ) +}) + +describe("installPackageReferences", () => { + it("treats an empty lockfile as a no-op and registers the canonical project", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + await initializeProject(projectPath, []) + const lockfilePath = join(projectPath, ".packref", "packref-lock.json") + const before = await readFile(lockfilePath, "utf8") + + const result = await runInstall(projectPath, home) + const config = JSON.parse( + await readFile(join(home, ".agents", "packref", "config.json"), "utf8") + ) + + expect(result).toMatchObject({ alreadyInstalled: [], fetched: [], reused: [] }) + expect(result.projectPath).toBe(await realpath(projectPath)) + expect(config.projects).toEqual([await realpath(projectPath)]) + expect(await readFile(lockfilePath, "utf8")).toBe(before) + }) + + it("fetches and materializes a locked repository package directory", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const entry = repositoryEntry("example", "1.0.0", "packages/example") + const controls = { repositoryDownloads: 0, tarballDownloads: 0 } + await initializeProject(projectPath, [entry]) + const lockfilePath = join(projectPath, ".packref", "packref-lock.json") + const before = await readFile(lockfilePath, "utf8") + + const result = await runInstall(projectPath, home, controls) + const referencePath = getReferencePath(projectPath, entry) + + expect(result.fetched).toEqual([entry]) + expect(controls.repositoryDownloads).toBe(1) + expect(await readFile(join(referencePath, "index.ts"), "utf8")).toBe("package source") + expect(await exists(join(referencePath, "README.md"))).toBe(false) + expect(await readFile(lockfilePath, "utf8")).toBe(before) + }) + + it("fetches and materializes a locked repository package at the repository root", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const entry = repositoryEntry("example", "1.0.0") + const controls = { repositoryDownloads: 0, tarballDownloads: 0 } + await initializeProject(projectPath, [entry]) + + const result = await runInstall(projectPath, home, controls) + const referencePath = getReferencePath(projectPath, entry) + + expect(result.fetched).toEqual([entry]) + expect(controls.repositoryDownloads).toBe(1) + expect(await readFile(join(referencePath, "README.md"), "utf8")).toBe("repository root") + }) + + it("fetches and materializes a locked tarball", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const entry = tarballEntry("example", "1.0.0", "dependency") + const controls = { repositoryDownloads: 0, tarballDownloads: 0 } + await initializeProject(projectPath, [entry]) + + const result = await runInstall(projectPath, home, controls) + + expect(result.fetched).toEqual([entry]) + expect(controls.tarballDownloads).toBe(1) + expect( + await readFile(join(getReferencePath(projectPath, entry), "SOURCE.md"), "utf8") + ).toContain(entry.source.url) + }) + + it("reuses a compatible global entry without fetching", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const entry = tarballEntry("example", "1.0.0") + const controls = { repositoryDownloads: 0, tarballDownloads: 0 } + await initializeProject(projectPath, [entry]) + await materializeStoredEntry(home, entry) + + const result = await runInstall(projectPath, home, controls) + + expect(result.reused).toEqual([entry]) + expect(controls.tarballDownloads).toBe(0) + expect(await exists(getReferencePath(projectPath, entry))).toBe(true) + }) + + it("skips an existing project reference without inspecting or fetching the store", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const entry = repositoryEntry("example", "1.0.0") + await initializeProject(projectPath, [entry]) + const referencePath = getReferencePath(projectPath, entry) + await mkdir(referencePath, { recursive: true }) + await writeFile(join(referencePath, "SOURCE.md"), "existing") + + const result = await runInstall(projectPath, home) + + expect(result.alreadyInstalled).toEqual([entry]) + expect(await readFile(join(referencePath, "SOURCE.md"), "utf8")).toBe("existing") + }) + + it("installs manual, dependency-tracked, scoped, and multiple-version entries", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const entries = [ + tarballEntry("@scope/pkg", "1.0.0", "manual"), + tarballEntry("example", "1.0.0", "dependency"), + tarballEntry("example", "2.0.0", "manual"), + ] + await initializeProject(projectPath, entries.toReversed()) + await Promise.all(entries.map((entry) => materializeStoredEntry(home, entry))) + + const result = await runInstall(projectPath, home) + + expect(result.reused).toEqual(entries) + await Promise.all( + entries.map(async (entry) => { + expect(await exists(getReferencePath(projectPath, entry))).toBe(true) + }) + ) + }) + + it("rejects a global store entry whose source differs from the lockfile", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const lockedEntry = tarballEntry("example", "1.0.0") + const storedEntry = tarballEntry( + "example", + "1.0.0", + "manual", + "https://registry.npmjs.org/example/-/different.tgz" + ) + await initializeProject(projectPath, [lockedEntry]) + await materializeStoredEntry(home, storedEntry) + + let mismatch: unknown + + try { + await runInstall(projectPath, home) + } catch (error) { + mismatch = error + } + + expect(mismatch).toBeInstanceOf(StoreSourceMismatchError) + expect(await exists(getReferencePath(projectPath, lockedEntry))).toBe(false) + }) + + it("keeps completed references after failure and resumes without rewriting the lockfile", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const first = tarballEntry("first", "1.0.0") + const second = tarballEntry("second", "1.0.0") + await initializeProject(projectPath, [first, second]) + const lockfilePath = join(projectPath, ".packref", "packref-lock.json") + const before = await readFile(lockfilePath, "utf8") + const failingControls = { + failedTarballUrl: second.source.url, + repositoryDownloads: 0, + tarballDownloads: 0, + } + + let failure: unknown + + try { + await runInstall(projectPath, home, failingControls) + } catch (error) { + failure = error + } + + expect(failure).toBeInstanceOf(TarballFetchError) + expect(await exists(getReferencePath(projectPath, first))).toBe(true) + expect(await exists(getReferencePath(projectPath, second))).toBe(false) + expect(await readFile(lockfilePath, "utf8")).toBe(before) + + const result = await runInstall(projectPath, home) + + expect(result.alreadyInstalled).toEqual([first]) + expect(result.fetched).toEqual([second]) + expect(await exists(getReferencePath(projectPath, second))).toBe(true) + expect(await readFile(lockfilePath, "utf8")).toBe(before) + }) + + it("rejects absent projects and malformed lockfiles", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + + let notInitialized: unknown + + try { + await runInstall(projectPath, home) + } catch (error) { + notInitialized = error + } + + expect(notInitialized).toMatchObject({ _tag: "NotInitializedError" }) + + await mkdir(join(projectPath, ".packref"), { recursive: true }) + await writeFile(join(projectPath, ".packref", "packref-lock.json"), "{") + + let malformed: unknown + + try { + await runInstall(projectPath, home) + } catch (error) { + malformed = error + } + + expect(malformed).toMatchObject({ _tag: "LockfileParseError" }) + }) + + it("rejects duplicate lockfile identities before materializing either source", async () => { + const projectPath = await makeTempDirectory() + const home = await makeTempDirectory() + const first = tarballEntry("example", "1.0.0") + const second = tarballEntry( + "example", + "1.0.0", + "manual", + "https://registry.npmjs.org/example/-/different.tgz" + ) + const controls = { repositoryDownloads: 0, tarballDownloads: 0 } + await initializeProject(projectPath, [first, second]) + + let duplicate: unknown + + try { + await runInstall(projectPath, home, controls) + } catch (error) { + duplicate = error + } + + expect(duplicate).toMatchObject({ + _tag: "LockfileParseError", + cause: expect.objectContaining({ + message: "Duplicate package identity: npm:example@1.0.0", + }), + }) + expect(controls.tarballDownloads).toBe(0) + expect(await exists(getReferencePath(projectPath, first))).toBe(false) + expect(await exists(join(home, ".agents", "packref", "config.json"))).toBe(false) + }) +}) diff --git a/src/lib/references/install.ts b/src/lib/references/install.ts new file mode 100644 index 0000000..04848a5 --- /dev/null +++ b/src/lib/references/install.ts @@ -0,0 +1,97 @@ +import * as Effect from "effect/Effect" +import { StoreSourceMismatchError } from "#lib/core/errors.ts" +import { packageSourceEquivalence } from "#lib/core/source.ts" +import { fetchRepositorySnapshot } from "#lib/sources/repository/fetch.ts" +import { resolveRepositoryRef } from "#lib/sources/repository/normalize.ts" +import { fetchTarballSnapshot } from "#lib/sources/tarball/fetch.ts" +import { hasStoreEntry, readStoreEntry, type StoredEntry } from "#lib/store/store.ts" +import { registerProject } from "#lib/workspace/config.ts" +import { + listPackageEntries, + readProjectLockfile, + type PackageEntry, +} from "#lib/workspace/lockfile.ts" +import { + createProjectReference, + hasProjectReference, + requireInitializedProject, +} from "#lib/workspace/project.ts" + +export interface InstallPackageReferencesOptions { + readonly projectPath?: string +} + +export interface InstallPackageReferencesResult { + readonly alreadyInstalled: readonly PackageEntry[] + readonly fetched: readonly PackageEntry[] + readonly projectPath: string + readonly reused: readonly PackageEntry[] +} + +const ensureMatchingSource = Effect.fn("ensureMatchingSource")(function* ( + entry: PackageEntry, + storedEntry: StoredEntry +) { + if (!packageSourceEquivalence(entry.source, storedEntry.source)) { + return yield* new StoreSourceMismatchError({ + name: entry.name, + registry: entry.registry, + version: entry.version, + }) + } + + return storedEntry +}) + +const fetchLockedStoreEntry = Effect.fn("fetchLockedStoreEntry")(function* (entry: PackageEntry) { + if (entry.source.type === "tarball") { + const materialized = yield* fetchTarballSnapshot(entry, entry.source.url) + return yield* ensureMatchingSource(entry, materialized) + } + + const resolvedRepository = yield* resolveRepositoryRef(entry, entry.source) + const materialized = yield* fetchRepositorySnapshot(entry, resolvedRepository) + return yield* ensureMatchingSource(entry, materialized) +}) + +export const installPackageReferences = Effect.fn("installPackageReferences")(function* ( + options: InstallPackageReferencesOptions = {} +) { + const projectPath = yield* requireInitializedProject(options.projectPath) + const lockfile = yield* readProjectLockfile(projectPath) + const entries = listPackageEntries(lockfile) + const alreadyInstalled: PackageEntry[] = [] + const fetched: PackageEntry[] = [] + const reused: PackageEntry[] = [] + + yield* registerProject(projectPath) + + for (const entry of entries) { + if (yield* hasProjectReference(projectPath, entry)) { + alreadyInstalled.push(entry) + continue + } + + const reusedStoreEntry = yield* hasStoreEntry(entry) + const storeEntry = yield* reusedStoreEntry + ? readStoreEntry(entry).pipe( + Effect.flatMap((storedEntry) => ensureMatchingSource(entry, storedEntry)) + ) + : fetchLockedStoreEntry(entry) + + yield* createProjectReference(projectPath, entry, storeEntry.path, entry.source) + + if (reusedStoreEntry) { + reused.push(entry) + } else { + fetched.push(entry) + } + } + + return { + alreadyInstalled, + fetched, + projectPath, + reused, + } satisfies InstallPackageReferencesResult +}) diff --git a/src/lib/sources/repository/fetch.ts b/src/lib/sources/repository/fetch.ts index 617f89f..790071a 100644 --- a/src/lib/sources/repository/fetch.ts +++ b/src/lib/sources/repository/fetch.ts @@ -49,7 +49,9 @@ export const fetchRepositorySnapshot = Effect.fn("fetchRepositorySnapshot")(func const downloader = yield* RepositoryDownloader const fetchSource = resolvedRepository.source.fetchSource const source = { - directory: resolvedRepository.source.directory, + ...(resolvedRepository.source.directory === undefined + ? {} + : { directory: resolvedRepository.source.directory }), host: resolvedRepository.source.host, type: "repository", url: resolvedRepository.source.url, diff --git a/src/lib/sources/repository/normalize.ts b/src/lib/sources/repository/normalize.ts index 816f3b9..90d3a95 100644 --- a/src/lib/sources/repository/normalize.ts +++ b/src/lib/sources/repository/normalize.ts @@ -82,7 +82,7 @@ const normalizeFromStandardUrl = ( const url = `https://${host}/${repositoryPath}` return { - directory: candidate.directory, + ...(candidate.directory === undefined ? {} : { directory: candidate.directory }), fetchSource: getProviderFetchSource(host, repositoryPath), host, type: "repository", @@ -131,7 +131,7 @@ const normalizeFromScpLikeUrl = ( const url = `https://${host}/${repositoryPath}` return Effect.succeed({ - directory: candidate.directory, + ...(candidate.directory === undefined ? {} : { directory: candidate.directory }), fetchSource: getProviderFetchSource(host, repositoryPath), host, type: "repository", @@ -163,7 +163,7 @@ const normalizeFromShorthandUrl = ( const url = `https://${host}/${urlRepositoryPath}` return Effect.succeed({ - directory: candidate.directory, + ...(candidate.directory === undefined ? {} : { directory: candidate.directory }), fetchSource: `${provider}:${fetchRepositoryPath}`, host, type: "repository", diff --git a/src/lib/store/store.ts b/src/lib/store/store.ts index c3fdf4e..09c0b86 100644 --- a/src/lib/store/store.ts +++ b/src/lib/store/store.ts @@ -27,6 +27,11 @@ export interface MaterializedStoreEntry { readonly source: PackageSource } +export interface StoredEntry { + readonly path: string + readonly source: PackageSource +} + const StoreEntryMetadataSchema = Schema.Struct({ source: PackageSourceSchema, }) @@ -52,6 +57,36 @@ export const hasStoreEntry = Effect.fn("hasStoreEntry")(function* (identity: Pac return yield* fs.exists(entryPath) }) +export const readStoreEntry = Effect.fn("readStoreEntry")(function* (identity: PackageIdentity) { + const fs = yield* FileSystem.FileSystem + const { entryPath, metadataPath } = yield* getStoreEntryPaths(identity) + const rawMetadata = yield* fs.readFileString(metadataPath).pipe( + Effect.mapError( + (cause) => + new StoreCorruptedError({ + cause, + path: entryPath, + }) + ) + ) + const metadata = yield* Schema.decodeUnknownEffect(StoreEntryMetadataJsonSchema)( + rawMetadata + ).pipe( + Effect.mapError( + (cause) => + new StoreCorruptedError({ + cause, + path: entryPath, + }) + ) + ) + + return { + path: entryPath, + source: metadata.source, + } satisfies StoredEntry +}) + const listDirectoryOrEmpty = Effect.fn("listDirectoryOrEmpty")(function* (directoryPath: string) { const fs = yield* FileSystem.FileSystem @@ -149,31 +184,12 @@ export const materializeStoreEntry = Effect.fn("materializeStoreEntry")(function const exists = yield* fs.exists(storePath) if (exists) { - const rawMetadata = yield* fs.readFileString(metadataPath).pipe( - Effect.mapError( - (cause) => - new StoreCorruptedError({ - cause, - path: storePath, - }) - ) - ) - const metadata = yield* Schema.decodeUnknownEffect(StoreEntryMetadataJsonSchema)( - rawMetadata - ).pipe( - Effect.mapError( - (cause) => - new StoreCorruptedError({ - cause, - path: storePath, - }) - ) - ) + const storedEntry = yield* readStoreEntry(identity) return { path: storePath, reused: true, - source: metadata.source, + source: storedEntry.source, } satisfies MaterializedStoreEntry } diff --git a/src/lib/workspace/integration.ts b/src/lib/workspace/integration.ts index 3dad72f..282e0e7 100644 --- a/src/lib/workspace/integration.ts +++ b/src/lib/workspace/integration.ts @@ -5,7 +5,14 @@ import * as Path from "effect/Path" import * as Schema from "effect/Schema" import { applyEdits, modify, type ParseError, parse } from "jsonc-parser" -const PACKREF_IGNORE_ENTRY = ".packref" +const LEGACY_PACKREF_IGNORE_ENTRIES = new Set([".packref", ".packref/"]) +const PACKREF_PACKAGES_IGNORE_ENTRY = ".packref/packages/" +const PACKREF_TEMP_LOCKFILE_IGNORE_ENTRY = ".packref/.packref-lock-*.tmp" +const PACKREF_IGNORE_ENTRIES = [ + PACKREF_PACKAGES_IGNORE_ENTRY, + PACKREF_TEMP_LOCKFILE_IGNORE_ENTRY, +] as const +const PACKREF_IGNORE_ENTRY_SET = new Set(PACKREF_IGNORE_ENTRIES) const GITIGNORE_NAME = ".gitignore" const TSCONFIG_NAME = "tsconfig.json" const AGENTS_NAME = "AGENTS.md" @@ -18,11 +25,13 @@ const PACKREF_AGENTS_BODY = `## Packref 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////\` for unscoped packages and \`.packref/packages/////\` 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 @@ -53,8 +62,7 @@ const readOptionalFile = (path: string) => return yield* fs.readFileString(path) }).pipe(Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () => Effect.void)) -const checkIsPackrefEntry = (entry: string) => - entry === PACKREF_IGNORE_ENTRY || entry === `${PACKREF_IGNORE_ENTRY}/` +const checkIsPackrefEntry = (entry: string) => LEGACY_PACKREF_IGNORE_ENTRIES.has(entry.trim()) export const ensureGitignoreEntry = (projectPath: string) => Effect.gen(function* () { @@ -63,19 +71,39 @@ export const ensureGitignoreEntry = (projectPath: string) => const gitignorePath = path.join(projectPath, GITIGNORE_NAME) const existing = yield* readOptionalFile(gitignorePath) - if ( - existing - ?.split(/\r?\n/) - .map((line) => line.trim()) - .some((line) => checkIsPackrefEntry(line)) - ) { - return + const content = existing ?? "" + const newline = content.includes("\r\n") ? "\r\n" : "\n" + const hasFinalNewline = content.endsWith(newline) + const body = hasFinalNewline ? content.slice(0, -newline.length) : content + const lines = body.length === 0 ? [] : body.split(newline) + const nextLines: string[] = [] + const foundEntries = new Set(lines.filter((line) => PACKREF_IGNORE_ENTRY_SET.has(line))) + + for (const line of lines) { + if (!checkIsPackrefEntry(line)) { + nextLines.push(line) + continue + } + + for (const entry of PACKREF_IGNORE_ENTRIES) { + if (!foundEntries.has(entry)) { + nextLines.push(entry) + foundEntries.add(entry) + } + } } - const content = existing ?? "" - const separator = content.length === 0 ? "" : content.endsWith("\n") ? "" : "\n" + for (const entry of PACKREF_IGNORE_ENTRIES) { + if (!foundEntries.has(entry)) { + nextLines.push(entry) + } + } - yield* fs.writeFileString(gitignorePath, `${content}${separator}${PACKREF_IGNORE_ENTRY}\n`) + const nextContent = `${nextLines.join(newline)}${existing === undefined || hasFinalNewline ? newline : ""}` + + if (nextContent !== content) { + yield* fs.writeFileString(gitignorePath, nextContent) + } }) export const ensureTsconfigExclude = (projectPath: string) => @@ -102,7 +130,7 @@ export const ensureTsconfigExclude = (projectPath: string) => return "updated" } - const edits = modify(existing, ["exclude"], [...(parsed.exclude ?? []), PACKREF_IGNORE_ENTRY], { + const edits = modify(existing, ["exclude"], [...(parsed.exclude ?? []), ".packref"], { formattingOptions: { insertSpaces: true, tabSize: 2 }, }) diff --git a/src/lib/workspace/lockfile.ts b/src/lib/workspace/lockfile.ts index 41e9542..2271c57 100644 --- a/src/lib/workspace/lockfile.ts +++ b/src/lib/workspace/lockfile.ts @@ -39,8 +39,7 @@ export const readLockfileAtPath = (lockfilePath: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const rawLockfile = yield* fs.readFileString(lockfilePath) - - return yield* Schema.decodeUnknownEffect(LockfileJsonSchema)(rawLockfile).pipe( + const lockfile = yield* Schema.decodeUnknownEffect(LockfileJsonSchema)(rawLockfile).pipe( Effect.mapError( (cause) => new LockfileParseError({ @@ -49,6 +48,23 @@ export const readLockfileAtPath = (lockfilePath: string) => }) ) ) + + for (const [index, entry] of lockfile.packages.entries()) { + if ( + lockfile.packages + .slice(0, index) + .some((candidate) => packageIdentityEquivalence(candidate, entry)) + ) { + return yield* new LockfileParseError({ + cause: new Error( + `Duplicate package identity: ${entry.registry}:${entry.name}@${entry.version}` + ), + path: lockfilePath, + }) + } + } + + return lockfile }) export const writeLockfileAtPath = (lockfilePath: string, lockfile: Lockfile) => diff --git a/src/lib/workspace/project.ts b/src/lib/workspace/project.ts index 6495bc9..2a00b49 100644 --- a/src/lib/workspace/project.ts +++ b/src/lib/workspace/project.ts @@ -38,6 +38,25 @@ export const requireInitializedProject = Effect.fn("requireInitializedProject")( return projectPath }) +export const getProjectReferencePath = Effect.fn("getProjectReferencePath")(function* ( + projectPath: string, + identity: PackageIdentity +) { + const path = yield* Path.Path + + return yield* getStorePackagePath(getDirectoryPath(path, projectPath), identity) +}) + +export const hasProjectReference = Effect.fn("hasProjectReference")(function* ( + projectPath: string, + identity: PackageIdentity +) { + const fs = yield* FileSystem.FileSystem + const referencePath = yield* getProjectReferencePath(projectPath, identity) + + return yield* fs.exists(referencePath) +}) + export const createProjectReference = Effect.fn("createProjectReference")(function* ( projectPath: string, identity: PackageIdentity,