Skip to content
Open
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: 3 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions packages/api/src/db/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [];
}
Expand Down
18 changes: 18 additions & 0 deletions packages/api/src/routes/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/routes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/services/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/services/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class RegistryService {
version: string,
bundle: Buffer,
userId: string,
force = false,
): Promise<AgentMetadata> {
// Get or create agent
let agent = this.db.getAgent(namespace, name);
Expand All @@ -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.`,
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)`,
);
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/utils/registry-client.test.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
);
});
});
7 changes: 6 additions & 1 deletion packages/cli/src/utils/registry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ export class RegistryClient {
namespace: string,
name: string,
version: string,
force = false,
): Promise<Record<string, unknown>> {
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: {
Expand Down