diff --git a/src/cli/commands/extension_fmt.ts b/src/cli/commands/extension_fmt.ts index b8c5101a..e26249d4 100644 --- a/src/cli/commands/extension_fmt.ts +++ b/src/cli/commands/extension_fmt.ts @@ -31,7 +31,10 @@ import { resolveRepoDir, } from "../context.ts"; import { requireInitializedRepo } from "../repo_context.ts"; -import { resolveExtensionFiles } from "../resolve_extension_files.ts"; +import { + isPulledExtensionManifest, + resolveExtensionFiles, +} from "../resolve_extension_files.ts"; import { UserError } from "../../domain/errors.ts"; interface ExtensionFmtOptions extends GlobalOptions { @@ -60,14 +63,19 @@ export const extensionFmtCommand = new Command() const cliCtx = createContext(options, ["extension", "fmt"]); cliCtx.logger.debug`Starting extension fmt`; - // 1. Validate repo const repoDir = resolveRepoDir(options.repoDir); + if (isPulledExtensionManifest(repoDir, manifestPath)) { + throw new UserError( + "Cannot run fmt on a pulled extension. Pulled extensions are read-only " + + "copies from the registry. To format a local extension, point at its manifest " + + "under your extensions/ directory instead.", + ); + } + const { repoContext } = await requireInitializedRepo({ repoDir, outputMode: cliCtx.outputMode, }); - - // 2. Resolve extension files (manifest, models, workflows, additional files) const { allModelFiles, allVaultFiles, diff --git a/src/cli/commands/extension_quality.ts b/src/cli/commands/extension_quality.ts index dfbcc867..efccb5cd 100644 --- a/src/cli/commands/extension_quality.ts +++ b/src/cli/commands/extension_quality.ts @@ -35,7 +35,10 @@ import { resolveRepoDir, } from "../context.ts"; import { requireInitializedRepo } from "../repo_context.ts"; -import { resolveExtensionFiles } from "../resolve_extension_files.ts"; +import { + isPulledExtensionManifest, + resolveExtensionFiles, +} from "../resolve_extension_files.ts"; import { UserError } from "../../domain/errors.ts"; interface ExtensionQualityOptions extends GlobalOptions { @@ -94,6 +97,14 @@ export const extensionQualityCommand = new Command() cliCtx.logger.debug`Starting extension quality`; const repoDir = resolveRepoDir(options.repoDir); + if (isPulledExtensionManifest(repoDir, manifestPath)) { + throw new UserError( + "Cannot run quality on a pulled extension. Pulled extensions are read-only " + + "copies from the registry. To score a local extension, point at its manifest " + + "under your extensions/ directory instead.", + ); + } + const { repoContext } = await requireInitializedRepo({ repoDir, outputMode: cliCtx.outputMode, diff --git a/src/cli/resolve_extension_files.ts b/src/cli/resolve_extension_files.ts index f94b79e2..2fe2b949 100644 --- a/src/cli/resolve_extension_files.ts +++ b/src/cli/resolve_extension_files.ts @@ -99,6 +99,18 @@ function normalizeAdditionalFileEntry(entry: string): string { return segments.join("/").toLowerCase(); } +export function isPulledExtensionManifest( + repoDir: string, + manifestPath: string, +): boolean { + const absolute = isAbsolute(manifestPath) + ? manifestPath + : resolve(repoDir, manifestPath); + const pulledRoot = join(resolve(repoDir), ".swamp", "pulled-extensions"); + return resolve(absolute).startsWith(pulledRoot + "/") || + resolve(absolute).startsWith(pulledRoot + "\\"); +} + export async function resolveExtensionFiles( ctx: ResolveExtensionFilesContext, ): Promise { diff --git a/src/cli/resolve_extension_files_test.ts b/src/cli/resolve_extension_files_test.ts index 968340c8..cdde4d1b 100644 --- a/src/cli/resolve_extension_files_test.ts +++ b/src/cli/resolve_extension_files_test.ts @@ -21,7 +21,10 @@ import { assertEquals, assertRejects } from "@std/assert"; import { join } from "@std/path"; import { stringify as stringifyYaml } from "@std/yaml"; import { getLogger } from "@logtape/logtape"; -import { resolveExtensionFiles } from "./resolve_extension_files.ts"; +import { + isPulledExtensionManifest, + resolveExtensionFiles, +} from "./resolve_extension_files.ts"; import { UserError } from "../domain/errors.ts"; import type { RepositoryContext } from "../infrastructure/persistence/repository_factory.ts"; @@ -858,3 +861,36 @@ Deno.test("resolveExtensionFiles default paths.base preserves swamp-extensions p assertEquals(result.modelEntryPoints, [join(modelsDir, "backups.ts")]); }); }); + +// ── isPulledExtensionManifest ────────────────────────────────────────── + +Deno.test("isPulledExtensionManifest: returns true for absolute path under .swamp/pulled-extensions", () => { + const repoDir = "/repo"; + const manifestPath = + "/repo/.swamp/pulled-extensions/@bixu/wheelshop/manifest.yaml"; + assertEquals(isPulledExtensionManifest(repoDir, manifestPath), true); +}); + +Deno.test("isPulledExtensionManifest: returns true for relative path under .swamp/pulled-extensions", () => { + const repoDir = "/repo"; + const manifestPath = ".swamp/pulled-extensions/@bixu/wheelshop/manifest.yaml"; + assertEquals(isPulledExtensionManifest(repoDir, manifestPath), true); +}); + +Deno.test("isPulledExtensionManifest: returns false for local extension manifest", () => { + const repoDir = "/repo"; + const manifestPath = "extensions/models/my-model/manifest.yaml"; + assertEquals(isPulledExtensionManifest(repoDir, manifestPath), false); +}); + +Deno.test("isPulledExtensionManifest: returns false for absolute local extension manifest", () => { + const repoDir = "/repo"; + const manifestPath = "/repo/extensions/models/my-model/manifest.yaml"; + assertEquals(isPulledExtensionManifest(repoDir, manifestPath), false); +}); + +Deno.test("isPulledExtensionManifest: returns false for path containing pulled-extensions outside .swamp", () => { + const repoDir = "/repo"; + const manifestPath = "/repo/pulled-extensions/manifest.yaml"; + assertEquals(isPulledExtensionManifest(repoDir, manifestPath), false); +});