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
14 changes: 10 additions & 4 deletions src/cli/auto_resolver_adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
SWAMP_SUBDIRS,
swampPath,
} from "../infrastructure/persistence/paths.ts";
import { readUpstreamExtensions } from "../infrastructure/persistence/upstream_extensions.ts";
import type { DenoRuntime } from "../domain/runtime/deno_runtime.ts";
import { join } from "@std/path";
import type {
Expand All @@ -35,6 +34,7 @@ import {
enumeratePulledExtensionDirs,
type ExtensionRegistryInfo,
installExtension,
LockfileRepository,
} from "../libswamp/mod.ts";
import { UserModelLoader } from "../domain/models/user_model_loader.ts";
import { UserVaultLoader } from "../domain/vaults/user_vault_loader.ts";
Expand Down Expand Up @@ -136,8 +136,8 @@ export function createAutoResolveInstallerAdapter(
// output, not source. Clearing the bundle cache (a normal hygiene
// operation) must not flip the inspection to truncated and steal
// the user-WIP path from issue #121.
const upstream = await readUpstreamExtensions(lockfilePath);
const entry = upstream[extensionName];
const inspectLockfileRepo = await LockfileRepository.create(lockfilePath);
const entry = inspectLockfileRepo.getEntry(extensionName);
if (!entry) return { state: "missing" };
const path = swampPath(repoDir, "pulled-extensions", extensionName);
try {
Expand Down Expand Up @@ -176,14 +176,20 @@ export function createAutoResolveInstallerAdapter(
// resolver cannot cover (e.g. two types resolving the same
// extension concurrently).
try {
// Construct a fresh LockfileRepository per install to capture a
// current snapshot — the InstallContext is single-use per its
// JSDoc.
const lockfileRepository = await LockfileRepository.create(
lockfilePath,
);
const result = await installExtension(
{ name: extensionName, version: null },
{
getExtension,
downloadArchive,
getChecksum,
logger,
lockfilePath,
lockfileRepository,
skillsDir: swampPath(repoDir, SWAMP_SUBDIRS.pulledSkills),
repoDir,
force: false,
Expand Down
6 changes: 5 additions & 1 deletion src/cli/auto_resolver_adapters_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createAutoResolveInstallerAdapter } from "./auto_resolver_adapters.ts";
import type { DenoRuntime } from "../domain/runtime/deno_runtime.ts";
import { ExtensionCatalogStore } from "../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionRepository } from "../infrastructure/persistence/extension_repository.ts";
import { LockfileRepository } from "../infrastructure/persistence/lockfile_repository.ts";
import { modelRegistry } from "../domain/models/model.ts";
import { ModelType } from "../domain/models/model_type.ts";
import type { ModelDefinition } from "../domain/models/model.ts";
Expand Down Expand Up @@ -56,7 +57,10 @@ function makeRepoForCatalog(
): ExtensionRepository {
return new ExtensionRepository({
catalog,
getLockedVersion: () => null,
lockfileRepository: new LockfileRepository(
"/test/repo/upstream_extensions.json",
{},
),
repoRoot,
});
}
Expand Down
9 changes: 5 additions & 4 deletions src/cli/commands/doctor_extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@ import {
getExtensionLoadWarnings,
resetExtensionLoadWarnings,
} from "../../infrastructure/logging/extension_load_warnings.ts";
import { readUpstreamExtensions } from "../../infrastructure/persistence/upstream_extensions.ts";
import { modelRegistry } from "../../domain/models/model.ts";
import { vaultTypeRegistry } from "../../domain/vaults/vault_type_registry.ts";
import { driverTypeRegistry } from "../../domain/drivers/driver_type_registry.ts";
import { datastoreTypeRegistry } from "../../domain/datastore/datastore_type_registry.ts";
import { reportRegistry } from "../../domain/reports/report_registry.ts";
import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts";
import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts";
import { swampPath } from "../../infrastructure/persistence/paths.ts";
import { createDoctorExtensionsRenderer } from "../../presentation/renderers/doctor_extensions.ts";
import {
Expand Down Expand Up @@ -119,12 +119,12 @@ export const doctorExtensionsCommand = new Command()
// command in the same repo.
// W1b: forceCatalogRescan(repoDir) → repository.invalidateAll().
try {
const upstream = await readUpstreamExtensions(lockfilePath);
const lockfileRepository = await LockfileRepository.create(lockfilePath);
const rescanRepo = new ExtensionRepository({
catalog: new ExtensionCatalogStore(
swampPath(repoDir, "_extension_catalog.db"),
),
getLockedVersion: (name) => upstream[name]?.version ?? null,
lockfileRepository,
repoRoot: repoDir,
});
try {
Expand Down Expand Up @@ -177,12 +177,13 @@ export const doctorExtensionsCommand = new Command()
const controller = new AbortController();
const renderer = createDoctorExtensionsRenderer(cliCtx.outputMode);

const doctorLockfileRepo = await LockfileRepository.create(lockfilePath);
await consumeStream(
doctorExtensions({
registries,
getWarnings: getExtensionLoadWarnings,
resetState: resetExtensionLoadWarnings,
readUpstreamExtensions: () => readUpstreamExtensions(lockfilePath),
lockfileRepository: doctorLockfileRepo,
repoDir,
skillsDir: repoRelativeSkillsDir,
abortSignal: controller.signal,
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/extension_outdated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const extensionOutdatedCommand = new Command()
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");

const ctx = createLibSwampContext({ logger: cliCtx.logger });
const deps = createExtensionUpdateDeps({
const deps = await createExtensionUpdateDeps({
lockfilePath,
serverUrl: resolveServerUrl(),
// outdated is read-only — installation is wired but never invoked
Expand Down
17 changes: 10 additions & 7 deletions src/cli/commands/extension_pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
extensionPull,
type ExtensionPullDeps,
type ExtensionRegistryInfo,
type LockfileRepository,
parseExtensionRef,
resolveServerUrl,
validateExtensionName,
Expand All @@ -62,9 +63,8 @@ export {
type InstallContext,
installExtension,
type InstallResult,
LockfileRepository,
parseExtensionRef,
removeUpstreamExtension,
updateUpstreamExtensions,
validateExtensionName,
} from "../../libswamp/mod.ts";

Expand All @@ -91,8 +91,11 @@ export interface PullContext {
downloadArchive: (name: string, version: string) => Promise<Uint8Array>;
getChecksum: (name: string, version: string) => Promise<string | null>;
logger: Logger;
/** Full path to the upstream_extensions.json lockfile. */
lockfilePath: string;
/**
* Lockfile repository owning read+write of upstream_extensions.json.
* Captures a snapshot at construction; construct fresh per pull.
*/
lockfileRepository: LockfileRepository;
/** Tool-aware skills destination (e.g. `.claude/skills/`). */
skillsDir: string;
repoDir: string;
Expand All @@ -115,7 +118,7 @@ export async function pullExtension(
getExtension: ctx.getExtension,
downloadArchive: ctx.downloadArchive,
getChecksum: ctx.getChecksum,
lockfilePath: ctx.lockfilePath,
lockfileRepository: ctx.lockfileRepository,
skillsDir: ctx.skillsDir,
repoDir: ctx.repoDir,
alreadyPulled: ctx.alreadyPulled,
Expand Down Expand Up @@ -208,7 +211,7 @@ export const extensionPullCommand = new Command()

// 7. Create deps via factory and pull
const serverUrl = resolveServerUrl();
const deps = createExtensionPullDeps(
const deps = await createExtensionPullDeps(
serverUrl,
lockfilePath,
skillsDir,
Expand All @@ -220,7 +223,7 @@ export const extensionPullCommand = new Command()
downloadArchive: deps.downloadArchive,
getChecksum: deps.getChecksum,
logger: ctx.logger,
lockfilePath,
lockfileRepository: deps.lockfileRepository,
skillsDir,
repoDir,
force: options.force ?? false,
Expand Down
42 changes: 18 additions & 24 deletions src/cli/commands/extension_pull_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import { assertEquals, assertThrows } from "@std/assert";
import { assertStringIncludes } from "@std/assert/string-includes";
import {
detectConflicts,
LockfileRepository,
parseExtensionRef,
updateUpstreamExtensions,
} from "./extension_pull.ts";
import type { UpstreamExtensionEntry } from "../../infrastructure/persistence/upstream_extensions.ts";
import { UserError } from "../../domain/errors.ts";
Expand Down Expand Up @@ -56,19 +56,18 @@ Deno.test("parseExtensionRef throws on empty version after @", () => {
assertStringIncludes(error.message, "Version cannot be empty");
});

Deno.test("updateUpstreamExtensions persists files array", async () => {
Deno.test("LockfileRepository.writeEntry persists files array", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
const files = [
"extensions/models/foo/bar.yaml",
"extensions/models/foo/baz.ts",
];
await updateUpstreamExtensions(lockfilePath, "@test/ext", "1.0.0", files);
const repo = await LockfileRepository.create(lockfilePath);
await repo.writeEntry("@test/ext", "1.0.0", files);

const content = await Deno.readTextFile(
join(tmpDir, "upstream_extensions.json"),
);
const content = await Deno.readTextFile(lockfilePath);
const data = JSON.parse(content) as Record<string, UpstreamExtensionEntry>;

assertEquals(data["@test/ext"].version, "1.0.0");
Expand All @@ -79,22 +78,18 @@ Deno.test("updateUpstreamExtensions persists files array", async () => {
}
});

Deno.test("updateUpstreamExtensions preserves existing entries", async () => {
Deno.test("LockfileRepository.writeEntry preserves existing entries", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
// Write first extension
await updateUpstreamExtensions(lockfilePath, "@test/first", "1.0.0", [
"a.yaml",
]);
// Write second extension
await updateUpstreamExtensions(lockfilePath, "@test/second", "2.0.0", [
"b.yaml",
]);

const content = await Deno.readTextFile(
join(tmpDir, "upstream_extensions.json"),
);
const repoFirst = await LockfileRepository.create(lockfilePath);
await repoFirst.writeEntry("@test/first", "1.0.0", ["a.yaml"]);
// Sibling instance simulates a second process; re-reads disk under
// lock so the merged write picks up the prior entry.
const repoSecond = await LockfileRepository.create(lockfilePath);
await repoSecond.writeEntry("@test/second", "2.0.0", ["b.yaml"]);

const content = await Deno.readTextFile(lockfilePath);
const data = JSON.parse(content) as Record<string, UpstreamExtensionEntry>;

assertEquals(data["@test/first"].version, "1.0.0");
Expand All @@ -106,15 +101,14 @@ Deno.test("updateUpstreamExtensions preserves existing entries", async () => {
}
});

Deno.test("updateUpstreamExtensions handles empty files array", async () => {
Deno.test("LockfileRepository.writeEntry handles empty files array", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
await updateUpstreamExtensions(lockfilePath, "@test/empty", "1.0.0", []);
const repo = await LockfileRepository.create(lockfilePath);
await repo.writeEntry("@test/empty", "1.0.0", []);

const content = await Deno.readTextFile(
join(tmpDir, "upstream_extensions.json"),
);
const content = await Deno.readTextFile(lockfilePath);
const data = JSON.parse(content) as Record<string, UpstreamExtensionEntry>;

assertEquals(data["@test/empty"].files, []);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/extension_rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const extensionRemoveCommand = new Command()

// Create libswamp context, deps, renderer
const libCtx = createLibSwampContext({ logger: ctx.logger });
const deps = createExtensionRmDeps(repoDir, lockfilePath);
const deps = await createExtensionRmDeps(repoDir, lockfilePath);
const renderer = createExtensionRmRenderer(ctx.outputMode);
const input = { extensionName: ref.name };

Expand Down
56 changes: 23 additions & 33 deletions src/cli/commands/extension_rm_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,26 @@

import { assertEquals } from "@std/assert";
import { join } from "@std/path";
import {
removeUpstreamExtension,
updateUpstreamExtensions,
} from "./extension_pull.ts";
import { LockfileRepository } from "./extension_pull.ts";
import {
readUpstreamExtensions,
type UpstreamExtensionEntry,
} from "../../infrastructure/persistence/upstream_extensions.ts";

Deno.test("removeUpstreamExtension removes entry and preserves others", async () => {
Deno.test("LockfileRepository.removeEntry removes entry and preserves others", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
// Set up two extensions
await updateUpstreamExtensions(lockfilePath, "@test/first", "1.0.0", [
"a.yaml",
]);
await updateUpstreamExtensions(lockfilePath, "@test/second", "2.0.0", [
"b.yaml",
]);
const repoFirst = await LockfileRepository.create(lockfilePath);
await repoFirst.writeEntry("@test/first", "1.0.0", ["a.yaml"]);
const repoSecond = await LockfileRepository.create(lockfilePath);
await repoSecond.writeEntry("@test/second", "2.0.0", ["b.yaml"]);

// Remove the first one
await removeUpstreamExtension(lockfilePath, "@test/first");
// Remove the first one via a fresh instance.
const repoRm = await LockfileRepository.create(lockfilePath);
await repoRm.removeEntry("@test/first");

const content = await Deno.readTextFile(
join(tmpDir, "upstream_extensions.json"),
);
const content = await Deno.readTextFile(lockfilePath);
const data = JSON.parse(content) as Record<string, UpstreamExtensionEntry>;

assertEquals(data["@test/first"], undefined);
Expand All @@ -56,20 +49,17 @@ Deno.test("removeUpstreamExtension removes entry and preserves others", async ()
}
});

Deno.test("removeUpstreamExtension handles non-existent extension gracefully", async () => {
Deno.test("LockfileRepository.removeEntry handles non-existent extension gracefully", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
await updateUpstreamExtensions(lockfilePath, "@test/first", "1.0.0", [
"a.yaml",
]);
const repo = await LockfileRepository.create(lockfilePath);
await repo.writeEntry("@test/first", "1.0.0", ["a.yaml"]);

// Removing a non-existent entry should not throw
await removeUpstreamExtension(lockfilePath, "@test/nonexistent");
// Removing a non-existent entry should not throw.
await repo.removeEntry("@test/nonexistent");

const content = await Deno.readTextFile(
join(tmpDir, "upstream_extensions.json"),
);
const content = await Deno.readTextFile(lockfilePath);
const data = JSON.parse(content) as Record<string, UpstreamExtensionEntry>;

assertEquals(data["@test/first"].version, "1.0.0");
Expand All @@ -78,16 +68,15 @@ Deno.test("removeUpstreamExtension handles non-existent extension gracefully", a
}
});

Deno.test("removeUpstreamExtension handles missing JSON file", async () => {
Deno.test("LockfileRepository.removeEntry handles missing JSON file", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
// Should not throw even when file doesn't exist
await removeUpstreamExtension(lockfilePath, "@test/nonexistent");
const repo = await LockfileRepository.create(lockfilePath);
// Should not throw even when file doesn't exist.
await repo.removeEntry("@test/nonexistent");

const content = await Deno.readTextFile(
join(tmpDir, "upstream_extensions.json"),
);
const content = await Deno.readTextFile(lockfilePath);
const data = JSON.parse(content) as Record<string, UpstreamExtensionEntry>;

assertEquals(Object.keys(data).length, 0);
Expand All @@ -100,7 +89,8 @@ Deno.test("readUpstreamExtensions reads existing entries", async () => {
const tmpDir = await Deno.makeTempDir({ prefix: "swamp_test_" });
try {
const lockfilePath = join(tmpDir, "upstream_extensions.json");
await updateUpstreamExtensions(lockfilePath, "@test/ext", "1.0.0", [
const repo = await LockfileRepository.create(lockfilePath);
await repo.writeEntry("@test/ext", "1.0.0", [
"extensions/models/foo.yaml",
]);

Expand Down
Loading
Loading