Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

shared

One small Go server that hosts many static sites with subdomain routing, an rsync-style deploy flow, and a batteries-included client-side JS API — a document DB with realtime subscriptions, an AI chat proxy, file uploads, websocket channels, and identity. Drop a <script src="/shared.js"> into any page and go.

No auth. Single user, trusted network only. Anyone who can reach the port can read and write everything. Run it on your LAN or behind a VPN, not the open internet.

Attribution

This project is a self-hosted reimplementation of the ideas behind Quick, Shopify's internal hosting platform that "lets anyone at Shopify ship a site in seconds." The architecture (deliberately simple single-server hosting, FTP-style deploys, a fixed client API with db/ai/uploads/websockets/identity, no permissions by design) comes from their engineering write-up; this codebase is an independent from-scratch implementation for personal use, and is not affiliated with Shopify.

Quickstart (NixOS)

direnv allow          # or: nix develop

# run the server
go run ./cmd/sharedd

# in another shell: build the CLI and deploy the example site
go build -o bin/ ./...
bin/shared deploy examples/hello --name hello

# open it
xdg-open http://hello.localhost:8787

The homepage at http://localhost:8787 lists all deployed sites, each with its on-disk size and last-updated time. Each site card also has a × button that deletes the site and all its data (same as shared rm).

Install

Two parts: sharedd, the server you host on one Linux box on your network, and shared, the CLI you run from any machine to deploy to it. The server only ships for Linux; the CLI ships for Linux and Windows.

Hosting the server

NixOS / Nix. The flake packages both binaries:

nix profile install github:sdelcore/shared

On NixOS, use the bundled module instead — it runs sharedd as a hardened systemd service with state in /var/lib/shared:

{
  inputs.shared.url = "github:sdelcore/shared";
}
{ inputs, ... }:
{
  imports = [ inputs.shared.nixosModules.default ];

  services.shared = {
    enable = true;
    baseHost = "shared.tap";     # sites at <name>.shared.tap
    openFirewall = true;
    # environmentFile = "/run/secrets/shared.env";  # OPENAI_BASE_URL, OPENAI_API_KEY
  };
}

Ubuntu / other Linux. Download a static binary tarball from the releases page and install it:

tar -xzf shared_*_linux_amd64.tar.gz
sudo install -m 0755 sharedd shared /usr/local/bin/

Or build from source. This needs Go 1.24+, which is newer than what Ubuntu 24.04's apt ships — install it from go.dev/dl:

CGO_ENABLED=0 go install github.com/sdelcore/shared/cmd/...@latest

The binaries are pure Go and fully static; nothing else is required. To run the server under systemd, drop this in /etc/systemd/system/shared.service:

[Unit]
Description=shared site platform
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/sharedd
Environment=SHARED_ADDR=:8787
Environment=SHARED_DATA=/var/lib/shared
Environment=SHARED_BASE_HOST=localhost
DynamicUser=yes
StateDirectory=shared
WorkingDirectory=/var/lib/shared
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now shared

Installing the CLI

Any machine on the network can deploy to the server; point the CLI at it with --server or $SHARED_SERVER (default http://localhost:8787).

Linux / macOS. The linux release tarball above includes the CLI, or:

go install github.com/sdelcore/shared/cmd/shared@latest

Windows (CLI only — the server is not supported on Windows). Download shared_<version>_windows_amd64.zip (or arm64) from the releases page, unzip shared.exe somewhere on PATH, and set the server once:

setx SHARED_SERVER http://shared.tap
shared list

Or let your agent do it. The repo ships an install-shared-cli agent skill that picks the right method for the current OS. A running server serves it, so no clone is needed:

# Claude Code
mkdir -p ~/.claude/skills/install-shared-cli
curl -sfo ~/.claude/skills/install-shared-cli/SKILL.md \
  "$SHARED_SERVER/api/skills/install-shared-cli"

CLI

The shared binary talks to the server over HTTP (--server, default http://localhost:8787 or $SHARED_SERVER).

shared init [dir]                 # scaffold index.html + a shared-sites agent skill
shared skill install [--force]    # install the shared-sites skill into ~/.claude/skills
shared deploy [dir] --name NAME   # pack a directory and deploy it (--force skips
                                  # the overwrite check)
shared list                       # deployed sites with size, views, last deployer
shared open NAME                  # print and open a site URL
shared versions NAME              # a site's saved versions, newest first
shared rollback NAME              # roll back to the newest saved version
shared rm NAME                    # delete a site and all its data
shared backup [file]              # download a gzipped tarball of all server data

shared init writes a minimal index.html and .claude/skills/shared-sites/SKILL.md (an agent skill documenting the client API) into a site directory, refusing to overwrite existing files. shared skill install writes that same skill to ~/.claude/skills/shared-sites/SKILL.md so it is available to agents in every project, not just a scaffolded one; it skips an existing file unless --force is given. shared backup defaults to shared-backup-<yyyymmdd-hhmmss>.tar.gz in the current directory.

Agent skills

The skills live in skills/ as ordinary markdown, are embedded in both binaries, and are served by a running server:

Endpoint Returns
GET /skill.md the shared-sites skill (base host only)
GET /api/skills the available skills and their URLs
GET /api/skills/<name> one skill as text/markdown

/skill.md is served on the base host only. On a site host that path belongs to the site, so a deployed file of the same name still wins.

shared skill install fetches from the server first and falls back to its built-in copy when the server is unreachable. The reason: an agent then reads the skill for the server it is deploying to, not the one the CLI was built from.

Subdomain routing

Each site lives at http://<name>.<base-host><port>/. Modern browsers resolve any *.localhost name to 127.0.0.1 without /etc/hosts entries, so http://hello.localhost:8787 just works out of the box. To serve under a real domain on your LAN, set SHARED_BASE_HOST (e.g. shared.lan) and point a wildcard DNS record at the box.

Per-site data endpoints (db, uploads, ws) scope strictly to the first label of the request's Host header — one site cannot reach another site's data, even from a visitor's browser. The deploy endpoint takes its target from ?site=. Site names must match ^[a-z0-9][a-z0-9-]{0,62}$.

Client API

Every site can load the shared client library:

<script src="/shared.js"></script>

The library scopes everything to the current site automatically.

shared.db

A JSON document store. Documents get server-managed id, createdAt, and updatedAt fields.

const posts = shared.db.collection('posts');

const doc  = await posts.create({ title: 'hi', body: '...' });
const all  = await posts.list();                  // sorted by createdAt
const one  = await posts.get(doc.id);
await posts.update(doc.id, { title: 'hello' });
await posts.delete(doc.id);

// realtime: fires on created / updated / deleted
const sub = posts.subscribe({
  onCreate: doc => console.log('created', doc),
  onUpdate: doc => console.log('updated', doc),
  onDelete: doc => console.log('deleted', doc),
});
// sub.close() to stop. On reconnect after a drop, subscribe re-syncs and
// replays any create/update/delete missed while disconnected.

shared.ai

A proxy to an OpenAI-compatible chat API (OpenAI, or a gateway like LiteLLM) — your key stays on the server.

const reply = await shared.ai.chat('Summarize this in one line: ...');

// or full control:
const res = await shared.ai.chat('hi', {
  system: 'Be terse.',
  max_tokens: 256,
});

// streaming: pass stream + onToken to receive content deltas as they arrive.
// Still resolves with the full assembled string.
const full = await shared.ai.chat('Write a haiku about the sea.', {
  stream: true,
  onToken: text => process.stdout.write(text),
});

Image generation returns a persistent upload URL for the current site:

const { url } = await shared.ai.image('a watercolor fox, minimal');
img.src = url;   // served from /uploads/<site>/<rand>-ai.png

// or with options:
const res = await shared.ai.image({ prompt: 'a logo', size: '512x512' });

Requires OPENAI_BASE_URL and OPENAI_API_KEY to be set on the server; image generation also needs SHARED_AI_IMAGE_MODEL (or a model in the call). Otherwise calls fail with an error message.

shared.uploads

const { url } = await shared.uploads.upload(fileInput.files[0]);
img.src = url;   // served from /uploads/<site>/<rand>-<name>

shared.ws

Per-site broadcast channels. Each message is relayed to every other member of the same channel.

const room = shared.ws.channel('lobby');
room.onMessage(msg => console.log(msg));
room.send('hello everyone');

shared.identity

const me = await shared.identity();   // { email, name }

Returns the contents of data/identity.json if present, otherwise a default derived from SHARED_USER (or $USER).

HTTP API

Method Path Description
POST /api/deploy?site=N Body: gzipped tarball of site dir → {"site","url","version"}; 409 on version conflict (see below)
POST /api/rollback?site=N Restore the newest saved version → {"site","url","version"}
GET /api/versions?site=N {"current":{"seq","time","deployer","source"},"versions":[{"timestamp"}]}, newest first
GET /api/sites {"sites":[{"name","updatedAt","bytes","views","deployer","deploys"}]} (bytes = total on-disk size)
DELETE /api/sites/{name} Remove a site and all its data → {"deleted":true}
GET /api/export Streams a gzipped tarball of the entire data directory
GET /api/db/{col} {"docs":[...]}
POST /api/db/{col} JSON body → created doc (201)
GET /api/db/{col}/{id} Doc, or 404 {"error":...}
PUT /api/db/{col}/{id} JSON body → updated doc
DELETE /api/db/{col}/{id} {"deleted":true}
GET /api/db/{col}/subscribe WebSocket; pushes {"type","doc"} events
POST /api/ai/chat {"messages",...}{"content","model","stop_reason"}; with "stream":true streams OpenAI SSE
POST /api/ai/image {"prompt","model?","size?"}{"url"} (201, saved to site uploads)
POST /api/uploads multipart field file{"url"} (201)
GET /api/identity {"email","name"}
GET /api/ws?channel=C WebSocket broadcast channel

Per-site endpoints (db, uploads, ws) take the site from the request's subdomain.

Environment variables

Variable Default Purpose
SHARED_ADDR :8787 Listen address
SHARED_DATA ./data Data directory
SHARED_BASE_HOST localhost Base host for subdomain routing
SHARED_KEEP_VERSIONS 3 Prior deploys to keep per site for rollback; 0 disables versioning
OPENAI_BASE_URL OpenAI-compatible base URL (e.g. http://llm.tools.tap/v1); enables /api/ai/chat
OPENAI_API_KEY API key/token for the above (e.g. LiteLLM master key)
SHARED_AI_MODEL claude-opus-4-8 Default AI model (e.g. zen/kimi-k2.6)
SHARED_AI_IMAGE_MODEL Model for /api/ai/image (e.g. gpt-image-1); required unless passed per request
SHARED_AI_RATE 30 Per-site AI requests/min (burst 10); 0 disables the limiter
SHARED_USER $USER Name/email for the default identity

Data layout

data/
  sites/<name>/            deployed static files, one dir per site
  versions/<site>/<ts>/    prior deploys kept for rollback, named by unix time
  db/<site>/<col>.json     document store, one JSON file per collection
  uploads/<site>/          uploaded files
  meta/<site>.json         deploy history + view counts
  identity.json            optional identity override: {"email","name"}

Everything is plain files — back it up with whatever you already use.

Deletion and rollback

Each replacement deploy moves the previous site into versions/<site>/<unix-timestamp>/ and prunes to the newest SHARED_KEEP_VERSIONS (default 3; set 0 to just discard the old copy).

shared versions mysite    # list saved versions, newest first
shared rollback mysite     # swap in the newest version; the current one is
                           # kept as a new version, so rollback is reversible
shared rm mysite           # delete the site, its db, uploads, and versions

shared rm (or DELETE /api/sites/<name>) removes everything for a site and evicts its in-memory collection state, closing any open subscriptions.

Deploy attribution and overwrite protection

Every deploy and rollback is recorded in meta/<site>.json with a sequence number, timestamp, and deployer identity, sent by the CLI as X-Shared-Deployer: the git email configured for the deployed directory plus the machine, e.g. you@example.com (user@hostname), or just user@hostname without git. shared list and shared versions show who deployed last.

Deploys are also guarded against accidental overwrites, git --force-with-lease style: the CLI remembers the version it last deployed (in .shared/state.json next to the site, never uploaded) and sends it as X-Shared-Prev-Version. If someone else deployed in between, the server answers 409 with their identity and the CLI asks before overwriting:

hello was deployed by alice@dayman at 2026-07-14T23:40:23Z since your last deploy — overwrite? [y/N]

With no local state (fresh checkout, new machine) the CLI instead asks the server who deployed last and warns if it wasn't you. --force (or the X-Shared-Force: 1 header) skips both checks; requests without a prev-version header (curl, older CLIs) are accepted unchecked.

Metrics

The server counts page views per site — GET requests for HTML documents, not assets or API calls — batched in memory and flushed to meta/<site>.json every 30 seconds, with per-day buckets kept for 30 days. Totals show up in shared list, on the homepage, and in GET /api/sites.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages