diff --git a/docs/cli.md b/docs/cli.md index e70fde2..1433285 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -82,10 +82,13 @@ Push a built `.agent` bundle to the registry. ```bash skrun push +skrun push --force ``` Requires authentication and a built `.agent` bundle (`skrun build` first). +`--force` overwrites an existing version in the local registry so you can re-push the same `agent.yaml` version during development. + ## skrun pull Download an agent from the registry. diff --git a/packages/api/src/db/memory.ts b/packages/api/src/db/memory.ts index db94b83..d7a9be0 100644 --- a/packages/api/src/db/memory.ts +++ b/packages/api/src/db/memory.ts @@ -67,6 +67,36 @@ export class MemoryDb { return version; } + replaceVersion( + agentId: string, + data: { version: string; size: number; bundle_key: string }, + ): AgentVersion { + const versions = this.versions.get(agentId) ?? []; + const nextVersion: AgentVersion = { + id: randomUUID(), + agent_id: agentId, + ...data, + pushed_at: new Date().toISOString(), + }; + const existingIndex = versions.findIndex((version) => version.version === data.version); + + if (existingIndex >= 0) { + versions[existingIndex] = nextVersion; + } else { + versions.push(nextVersion); + } + this.versions.set(agentId, versions); + + for (const agent of this.agents.values()) { + if (agent.id === agentId) { + agent.updated_at = nextVersion.pushed_at; + break; + } + } + + return nextVersion; + } + getVersions(agentId: string): AgentVersion[] { return this.versions.get(agentId) ?? []; } diff --git a/packages/api/src/routes/registry.test.ts b/packages/api/src/routes/registry.test.ts index 289995f..ff95134 100644 --- a/packages/api/src/routes/registry.test.ts +++ b/packages/api/src/routes/registry.test.ts @@ -64,6 +64,24 @@ describe("Registry Routes", () => { expect(body.error.code).toBe("VERSION_EXISTS"); }); + it("POST /push overwrites duplicate version when force=true", async () => { + await pushAgent(); + const updatedBundle = Buffer.from("force-overwrite-bundle"); + const res = await app.request("/api/agents/dev/test-agent/push?version=1.0.0&force=true", { + method: "POST", + headers: { ...authHeader, "Content-Type": "application/octet-stream" }, + body: updatedBundle, + }); + + expect(res.status).toBe(200); + + const pullRes = await app.request("/api/agents/dev/test-agent/pull/1.0.0", { + headers: authHeader, + }); + expect(pullRes.status).toBe(200); + expect(Buffer.from(await pullRes.arrayBuffer())).toEqual(updatedBundle); + }); + it("POST /push returns 400 without version param", async () => { const res = await app.request("/api/agents/dev/agent/push", { method: "POST", diff --git a/packages/api/src/routes/registry.ts b/packages/api/src/routes/registry.ts index c3e1eb9..1da9118 100644 --- a/packages/api/src/routes/registry.ts +++ b/packages/api/src/routes/registry.ts @@ -9,6 +9,7 @@ export function createRegistryRoutes(service: RegistryService): Hono { router.post("/agents/:namespace/:name/push", authMiddleware, async (c) => { const { namespace, name } = c.req.param(); const version = c.req.query("version"); + const force = c.req.query("force") === "true"; const user = getUser(c); if (!version) { @@ -34,7 +35,7 @@ export function createRegistryRoutes(service: RegistryService): Hono { try { const body = await c.req.arrayBuffer(); const buffer = Buffer.from(body); - const metadata = await service.push(namespace, name, version, buffer, user.id); + const metadata = await service.push(namespace, name, version, buffer, user.id, force); return c.json(metadata); } catch (err) { if (err instanceof RegistryError) { diff --git a/packages/api/src/services/registry.test.ts b/packages/api/src/services/registry.test.ts index 1703ae8..402d35d 100644 --- a/packages/api/src/services/registry.test.ts +++ b/packages/api/src/services/registry.test.ts @@ -45,6 +45,18 @@ describe("RegistryService", () => { ).rejects.toThrow("already exists"); }); + it("should overwrite duplicate version when forced", async () => { + await service.push("acme", "agent", "1.0.0", Buffer.from("v1"), "user-1"); + await service.push("acme", "agent", "1.0.0", Buffer.from("v2"), "user-1", true); + + const pulled = await service.pull("acme", "agent", "1.0.0"); + expect(pulled.buffer.toString()).toBe("v2"); + + const versions = await service.getVersions("acme", "agent"); + expect(versions).toHaveLength(1); + expect(versions[0].version).toBe("1.0.0"); + }); + it("should throw 404 on pull for non-existent agent", async () => { await expect(service.pull("x", "y")).rejects.toThrow(RegistryError); }); diff --git a/packages/api/src/services/registry.ts b/packages/api/src/services/registry.ts index 6080391..b42334c 100644 --- a/packages/api/src/services/registry.ts +++ b/packages/api/src/services/registry.ts @@ -25,6 +25,7 @@ export class RegistryService { version: string, bundle: Buffer, userId: string, + force = false, ): Promise { // Get or create agent let agent = this.db.getAgent(namespace, name); @@ -39,7 +40,7 @@ export class RegistryService { // Check duplicate version const existing = this.db.getVersionByNumber(agent.id, version); - if (existing) { + if (existing && !force) { throw new RegistryError( "VERSION_EXISTS", `Version ${version} already exists for ${namespace}/${name}. Bump version in agent.yaml.`, @@ -52,7 +53,7 @@ export class RegistryService { await this.storage.put(bundleKey, bundle); // Create version record - this.db.createVersion(agent.id, { + this.db.replaceVersion(agent.id, { version, size: bundle.length, bundle_key: bundleKey, diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index f6bf7e3..64327fc 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -11,7 +11,8 @@ export function registerPushCommand(program: Command): void { program .command("push") .description("Push agent to the Skrun registry") - .action(async () => { + .option("--force", "Overwrite an existing version in the local registry") + .action(async (opts: { force?: boolean }) => { const dir = process.cwd(); // Check auth @@ -47,7 +48,7 @@ export function registerPushCommand(program: Command): void { // Push const client = new RegistryClient(getRegistryUrl(), token); try { - await client.push(bundle, namespace, slug, version); + await client.push(bundle, namespace, slug, version, opts.force ?? false); format.success( `Pushed ${namespace}/${slug}@${version} (${(bundle.length / 1024).toFixed(1)} KB)`, ); diff --git a/packages/cli/src/utils/registry-client.test.ts b/packages/cli/src/utils/registry-client.test.ts new file mode 100644 index 0000000..908d2df --- /dev/null +++ b/packages/cli/src/utils/registry-client.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RegistryClient } from "./registry-client.js"; + +describe("RegistryClient.push", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("adds force=true when requested", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new RegistryClient("http://localhost:4000", "dev-token"); + await client.push(Buffer.from("bundle"), "dev", "agent", "1.0.0", true); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:4000/api/agents/dev/agent/push?version=1.0.0&force=true", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("omits force=true by default", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new RegistryClient("http://localhost:4000", "dev-token"); + await client.push(Buffer.from("bundle"), "dev", "agent", "1.0.0"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:4000/api/agents/dev/agent/push?version=1.0.0", + expect.objectContaining({ + method: "POST", + }), + ); + }); +}); diff --git a/packages/cli/src/utils/registry-client.ts b/packages/cli/src/utils/registry-client.ts index 17dd295..cd74878 100644 --- a/packages/cli/src/utils/registry-client.ts +++ b/packages/cli/src/utils/registry-client.ts @@ -13,8 +13,13 @@ export class RegistryClient { namespace: string, name: string, version: string, + force = false, ): Promise> { - const url = `${this.baseUrl}/api/agents/${namespace}/${name}/push?version=${version}`; + const params = new URLSearchParams({ version }); + if (force) { + params.set("force", "true"); + } + const url = `${this.baseUrl}/api/agents/${namespace}/${name}/push?${params.toString()}`; const res = await fetch(url, { method: "POST", headers: {