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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ node_modules
*.log
.DS_Store
.agents
.celld
.wrangler
.dev.vars
.dev.vars.*
.env
.env.*
artifacts
Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,85 @@ jobs:
name: artifacts-${{ matrix.os }}
path: dist/binaries/*
if-no-files-found: error

docker-test:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
platform: linux/amd64
arch: amd64
- os: ubuntu-24.04-arm
platform: linux/arm64
arch: arm64
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0
- run: bun install --frozen-lockfile
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
load: true
tags: artifacts:test
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
- name: Test compilation, persistence, and graceful shutdown
env:
ARTIFACTS_DOCKER_IMAGE: artifacts:test
run: bun test scripts/docker.integration.test.ts --timeout 240000

docker-publish:
needs: [check, executable, docker-test]
if: github.repository == 'sidequery/artifacts' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: metadata
with:
images: ghcr.io/sidequery/artifacts
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=long
type=ref,event=tag
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: |
type=gha,scope=docker-amd64
type=gha,scope=docker-arm64
- name: Verify anonymous access
env:
IMAGE: ghcr.io/sidequery/artifacts:sha-${{ github.sha }}
run: |
mkdir -p "$RUNNER_TEMP/public-docker"
docker --config "$RUNNER_TEMP/public-docker" manifest inspect "$IMAGE" > /dev/null || {
echo 'Set the artifacts container package visibility to Public in GitHub package settings, then rerun this job.' >&2
exit 1
}
71 changes: 26 additions & 45 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,47 +1,28 @@
FROM debian:bookworm-slim

ENV DEBIAN_FRONTEND=noninteractive
ENV SHELL=/bin/bash
ENV TERM=xterm-256color
ENV HERDR_E2E=1
ENV HERDR_SESSION=artifact-e2e
ENV PATH="/usr/local/bin:${PATH}"

RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
libasound2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
util-linux \
unzip \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://bun.sh/install | bash

RUN curl -fsSL https://herdr.dev/install.sh | sh

RUN install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& install -m 0755 /root/.local/bin/herdr /usr/local/bin/herdr \
&& useradd --create-home --shell /bin/bash artifact

RUN curl -fsSL https://terminal-browser.sh/install \
| XDG_DATA_HOME=/opt XDG_BIN_HOME=/usr/local/bin AGENT_SKILLS_HOME=/tmp/terminal-browser-skills TERMINAL_BROWSER_SKIP_EDITOR_SETUP=1 bash

# syntax=docker/dockerfile:1
FROM oven/bun:1.4.0 AS build
WORKDIR /src
COPY package.json bun.lock tsconfig.json herdr-plugin.toml ./
COPY src ./src
COPY e2e ./e2e
COPY examples ./examples
COPY skills ./skills

COPY package.json bun.lock ./
COPY patches ./patches
RUN bun install --frozen-lockfile

ENV HOME=/home/artifact
USER artifact

CMD ["bun", "e2e/inside.ts"]
COPY . .
RUN bun run build:cloudflare --minify && bun run scripts/prepare-container.ts \
&& find dist/worker-app -name '*.map' -delete \
&& mkdir -p /state

FROM gcr.io/distroless/cc-debian13:nonroot AS runtime
LABEL org.opencontainers.image.source="https://github.com/sidequery/artifacts" \
org.opencontainers.image.description="Sidequery Artifacts with celld" \
org.opencontainers.image.licenses="MIT"
COPY --from=build --chown=65532:65532 /state/ /app/.celld/
COPY --from=build /src/dist/container/bin/ /usr/local/bin/
COPY --from=build /src/dist/worker-app/ /app/dist/worker-app/
COPY --from=build /src/dist/cloudflare/assets/ /app/dist/cloudflare/assets/
COPY --from=build /src/dist/container/wrangler.jsonc /app/wrangler.jsonc
ENV CELLD_ESBUILD=/usr/local/bin/esbuild CELLD_IDLE_EVICT_S=60
WORKDIR /app
USER 65532:65532
VOLUME ["/app/.celld"]
EXPOSE 4786
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/local/bin/celld"]
CMD ["dev", "/app", "--host", "0.0.0.0", "--port", "4786", "--no-watch", "--logs"]
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,54 @@ Use `artifacts host install` to enable startup at login. See the

## Deploy

### Docker

The image at `ghcr.io/sidequery/artifacts:latest` supports Linux amd64 and arm64.
It runs celld directly, with the application and native esbuild already included.
Bun is used only during the build. The final distroless image has no Bun, Node,
shell, package manager, or `node_modules` directory and runs as UID/GID 65532.

```sh
docker run -d --name artifacts --restart unless-stopped \
--stop-timeout 60 \
-p 127.0.0.1:4786:4786 \
-v artifacts-data:/app/.celld \
ghcr.io/sidequery/artifacts:latest
```

Open [localhost:4786](http://127.0.0.1:4786); the MCP endpoint is
`http://127.0.0.1:4786/mcp`. The named volume preserves apps, databases, files,
and schedules across container replacements. Stop the container before backing
up the volume. Allow 60 seconds for graceful shutdown.

The default command runs single-machine `celld dev` with authentication disabled,
so the example publishes the port on loopback only. For network access, configure
[authentication](docs/authentication.md#configure-celld) in a custom Wrangler
config and mount it at `/app/wrangler.jsonc` (read-only), keeping the image's
`main` and assets paths. Set `ENVIRONMENT` to `production`; environment variables
passed with `docker -e` do not replace Wrangler `vars`. For bucket-backed nodes,
follow the [celld deployment guide](docs/celld-deployment.md). Arguments after the
image name are passed directly to celld; for example, `--help` lists commands.

Build and test locally:

```sh
docker build -t artifacts:local .
bun install --frozen-lockfile
ARTIFACTS_DOCKER_IMAGE=artifacts:local bun test scripts/docker.integration.test.ts
```

CI builds and tests both image architectures on pushes and pull requests. Image
publishing waits for the application, executable, and Docker checks to pass. Pushes
to `main` publish `latest` and `sha-<full-commit>`; `v*` tags publish the matching
tag and commit tag. Pin a commit tag or image digest for repeatable deployments.
The package is public; CI checks anonymous access and fails if the
image is private. When publishing under a different package name, a package
administrator must set its visibility to **Public** in GitHub package settings
after the first push.

### Cloudflare and celld fleets

Configure sign-in before using a network deployment. The
[authentication guide](docs/authentication.md) covers provider setup, Cloudflare
Access, MCP OAuth, and troubleshooting. The default local host needs no sign-in.
Expand Down Expand Up @@ -177,4 +225,5 @@ bun run test:cloudflare
Browser tests require `bun x playwright install chromium`. Additional suites cover
[MCP Apps and Herdr](docs/local-workspace.md#development-checks), celld
(`bun run test:celld`), and package installation (`bun run test:package`).
The root Dockerfile is an integration-test environment.
`e2e/Dockerfile` is the Herdr integration-test environment; the root Dockerfile
builds the standalone celld image.
29 changes: 17 additions & 12 deletions cloudflare/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ let runtime: Miniflare;
let runtimeOptions: ConstructorParameters<typeof Miniflare>[0];
let client: Client;
let origin: string;
// Compilation pauses can outlive the local server's idle connections. Avoid
// reusing those sockets in both direct requests and the MCP transport.
const fetch = (input: string | URL | Request, init: RequestInit = {}) => {
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
headers.set("Connection", "close");
return globalThis.fetch(input, { ...init, headers });
};
const source = 'import { Button, H1, Stack, useArtifactState } from "sidequery/artifacts";\nexport default function Artifact() { const [n, setN] = useArtifactState("n", 0); return <Stack><H1>Hosted artifact</H1><Button onClick={() => setN(n+1)}>Count {n}</Button></Stack>; }\n';
const counterClient = await readFile(new URL("../examples/counter.artifact.tsx", import.meta.url), "utf8");
const counterServer = await readFile(new URL("../examples/counter.artifact.server.ts", import.meta.url), "utf8");
Expand Down Expand Up @@ -55,7 +62,7 @@ beforeAll(async () => {
runtime = new Miniflare(runtimeOptions);
origin = (await runtime.ready).origin;
client = new Client({ name: "artifact-integration", version: "1" });
await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`)));
await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`), { fetch }));
}, 30000);
afterAll(async () => { await client?.close(); await runtime?.dispose(); });

Expand Down Expand Up @@ -209,7 +216,7 @@ test("native artifact SQLite works through MCP and the gallery, persists across
expect((await callCounter("GET", "other-counter")).value).toBe(0);
const otherWorkspace = new Client({ name: "other-workspace", version: "1" });
try {
await otherWorkspace.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=other`)));
await otherWorkspace.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=other`), { fetch }));
expect((await otherWorkspace.callTool({ name: "artifact_write", arguments: { name: "counter", contents: counterClient, server: counterServer } })).isError).not.toBe(true);
const isolated = await otherWorkspace.callTool({ name: "artifact_request", arguments: { name: "counter", request: { path: "/counter" } } });
expect(isolated.isError).not.toBe(true);
Expand All @@ -231,13 +238,13 @@ test("native artifact SQLite works through MCP and the gallery, persists across
try {
const page = await browser.newPage();
await page.goto(`${origin}/?workspace=test`);
await page.getByRole("button", { name: "counter", exact: true }).click();
await page.getByRole("button", { name: "counter", exact: true }).and(page.getByTitle("test/counter", { exact: true })).click();
const frame = page.frameLocator("iframe");
await frame.getByText("Count: 2", { exact: true }).waitFor();
await frame.getByRole("button", { name: "Increment" }).click();
await frame.getByText("Count: 3", { exact: true }).waitFor();
await page.reload();
await page.getByRole("button", { name: "counter", exact: true }).click();
await page.getByRole("button", { name: "counter", exact: true }).and(page.getByTitle("test/counter", { exact: true })).click();
await page.frameLocator("iframe").getByText("Count: 3", { exact: true }).waitFor();
} finally { await browser.close(); }
}, 60000);
Expand Down Expand Up @@ -270,7 +277,12 @@ test("gallery renders interactive sandboxed previews and serves exact archived s
await frame.getByRole("button", { name: "Count 1" }).waitFor();
expect(await page.locator("iframe").getAttribute("sandbox")).toBe("allow-scripts");
expect(errors).toEqual([]);
expect(pages).toEqual([0, 100]);
// Opening the live subscription reconciles the gallery again. Each refresh
// must still consume both pages, even when the item is already selected.
expect(pages.slice(0, 2)).toEqual([0, 100]);
for (let index = 0; index < pages.length; index++) {
expect(pages[index]).toBe(index % 2 === 0 ? 0 : 100);
}
} finally { await browser.close(); }
}, 60000);

Expand All @@ -291,13 +303,6 @@ test("all hosted surfaces fail closed off loopback, and mutations enforce origin
});

test("verified users have isolated private libraries and can collaborate in the team library", async () => {
// Compilation pauses can outlive the local server's idle connections. Avoid
// reusing those sockets in both direct requests and the MCP transport.
const fetch = (input: string | URL | Request, init: RequestInit = {}) => {
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
headers.set("Connection", "close");
return globalThis.fetch(input, { ...init, headers });
};
const { generateKeyPair, exportJWK, SignJWT } = await import("jose");
const { Response: RuntimeResponse } = await import("miniflare");
const keys = await generateKeyPair("RS256", { extractable: true });
Expand Down
4 changes: 2 additions & 2 deletions docs/local-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,9 @@ that the pane is owned by `herdr.artifacts`, and proves its server stops when th
pane closes.

GitHub Actions runs the typecheck and unit/service suite with Bun 1.4.0.
The root Dockerfile is an integration-test environment, not a production image.
`e2e/Dockerfile` is the Herdr integration-test environment. The root Dockerfile
builds the standalone celld image.
`bun run test:mcp-ui` exercises the real artifact in Chromium with an MCP Apps host.
Install its browser first with `bun x playwright install chromium`. CI runs this
browser suite, the unit/service suite, and a clean tarball install that exercises
the installed CLI, gallery, compilation, and stdio MCP server.

48 changes: 48 additions & 0 deletions e2e/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
FROM debian:bookworm-slim

ENV DEBIAN_FRONTEND=noninteractive
ENV SHELL=/bin/bash
ENV TERM=xterm-256color
ENV HERDR_E2E=1
ENV HERDR_SESSION=artifact-e2e
ENV PATH="/usr/local/bin:${PATH}"

RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
libasound2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
util-linux \
unzip \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://bun.sh/install | bash

RUN curl -fsSL https://herdr.dev/install.sh | sh

RUN install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& install -m 0755 /root/.local/bin/herdr /usr/local/bin/herdr \
&& useradd --create-home --shell /bin/bash artifact

RUN curl -fsSL https://terminal-browser.sh/install \
| XDG_DATA_HOME=/opt XDG_BIN_HOME=/usr/local/bin AGENT_SKILLS_HOME=/tmp/terminal-browser-skills TERMINAL_BROWSER_SKIP_EDITOR_SETUP=1 bash

WORKDIR /src
COPY package.json bun.lock tsconfig.json herdr-plugin.toml ./
COPY patches ./patches
COPY src ./src
COPY e2e ./e2e
COPY examples ./examples
COPY skills ./skills

RUN bun install --frozen-lockfile

ENV HOME=/home/artifact
USER artifact

CMD ["bun", "e2e/inside.ts"]
2 changes: 1 addition & 1 deletion e2e/herdr.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test(
return;
}

const build = Bun.spawn(["docker", "build", "-t", "artifacts-e2e", "."], {
const build = Bun.spawn(["docker", "build", "-f", "e2e/Dockerfile", "-t", "artifacts-e2e", "."], {
cwd: PLUGIN_ROOT,
stdout: "inherit",
stderr: "inherit",
Expand Down
Loading
Loading