open-links-sites is the control repo for many individual OpenLinks sites.
It keeps per-person source data in one place, then later phases build and deploy
path-based multi-site output through the upstream
open-links runtime.
Every managed person lives under people/<id>/ and follows one canonical source
shape:
people/
<id>/
person.json
profile.json
links.json
site.json
assets/
person.jsonstores orchestration metadata for this repo.profile.json,links.json, andsite.jsonmirror upstream OpenLinks as closely as possible.- Person-specific assets stay inside
people/<id>/assets/. - Generated workspaces live under
generated/and are never hand-edited.
.
├── .agents/
├── generated/
├── people/
├── schemas/
├── scripts/
│ └── lib/
└── templates/
└── default/
This repo uses Bun + TypeScript so the operator, validation, and materialization
scripts can stay close to the upstream open-links toolchain.
bun install
bun run typecheck
bun test
bun run validate
bun run preview
bun run dev
bun run manage:person -- --help
bun run refresh:people:caches -- --person staci-costopoulos
bun run refresh:people:caches -- --all
bun run scaffold:person -- --id alice-example --name "Alice Example"
bun run materialize:person -- --id alice-example
bun run build:person:site -- --id alice-example
bun run build:site
OPEN_LINKS_SITES_PUBLIC_ORIGIN="https://USER.github.io/open-links-sites" bun run release:verifyThe Phase 1 contract is defined in:
schemas/person.schema.jsonschemas/upstream/*.schema.jsontemplates/default/*.jsonscripts/lib/person-contract.ts
Later phases will add validation, scaffold flows, and generated per-person workspaces on top of this contract.
The preferred operator workflow is the repo-local manage-person skill at:
.agents/skills/manage-person/SKILL.md
That skill drives the underlying CLI surface:
bun run manage:person -- <action> [options]Supported actions:
createimportupdatedisablearchive
Phase 2/3 examples:
bun run manage:person -- create --name "Alice Example"
bun run manage:person -- create --name "Bob Example" --seed-url "https://linktr.ee/bob"
bun run manage:person -- import --source-url "https://linktr.ee/alice"
bun run manage:person -- import --person "alice-example" --manual-links $'GitHub https://github.com/alice\nWebsite https://alice.dev'
bun run manage:person -- import --person "alice-example" --source-url "https://linktr.ee/alice" --full-refresh
bun run manage:person -- update --person "alice-example" --headline "Builder and operator"
bun run manage:person -- update --person "Alice Example" --site-title "Alice Example | Links"
bun run manage:person -- disable --person "alice-example" --confirm
bun run manage:person -- archive --person "alice-example" --confirm --reason "Offboarded"Direct JSON editing remains the low-level fallback, not the preferred CRUD path.
Phase 1 establishes a deterministic low-level flow:
bun run scaffold:person -- --id <id> --name "<Name>"createspeople/<id>/from the default templates and copies placeholder assets.bun run validatechecks structure, schema compatibility, asset isolation, and placeholder guidance.bun run materialize:person -- --id <id>writes a disposablegenerated/<id>/workspace withdata/*.jsonand staged public assets.
Phase 3 extends manage-person into the migration/bootstrap path:
bun run manage:person -- import --source-url "<linktree-url>"calls the upstreamopen-linksLinktree extractor and imports profile, social-link, and content-link candidates.bun run manage:person -- import --manual-links "<freeform text>"normalizes pasted URLs when there is no crawlable source.- Imported data merges conservatively: curated source-of-truth data wins, placeholder scaffold content is replaced, source order is preserved, and obvious duplicate URLs are skipped.
- After source write, the repo materializes
generated/<id>/, runs upstreamopen-linksenrichment/cache scripts there, then syncs stable artifacts back intopeople/<id>/cache/.
Per-person helper artifacts now live alongside the canonical files:
people/
<id>/
cache/
rich-metadata.json
rich-enrichment-report.json
rich-public-cache.json
profile-avatar.json
profile-avatar.runtime.json
content-images.json
content-images.runtime.json
history/
followers/
index.json
<platform>.csv
profile-avatar/
content-images/
rich-authenticated/
imports/
source-snapshot.json
last-import.json
These helper artifacts support incremental reruns. They do not replace the canonical required files under people/<id>/.
The cache-refresh surface rebuilds helper caches and follower-history analytics
artifacts without rerunning the full
manage:person import mutation path:
bun run refresh:people:caches -- --person staci-costopoulos
bun run refresh:people:caches -- --allBehavior:
- The command materializes each selected active person into
generated/<id>/. - It runs the upstream enrichment/cache steps with a forced refresh.
- It appends the latest per-person follower/subscriber snapshots before validation.
- It syncs only
people/<id>/cache/**back into the repo. - Disabled or archived people are skipped during
--allrefreshes. - Targeted refreshes require an active person.
- The command exits non-zero if any selected person's refresh fails.
Safety contract:
- allowed writes:
people/<id>/cache/** - blocked writes:
people/<id>/person.json,profile.json,links.json,site.json - any out-of-scope write is treated as a blocking failure and the person is restored to its pre-refresh state before the command exits
Phase 4 adds the first centralized site-generation layer on top of the existing materialize/import contract:
bun run build:person:site -- --id <id>materializes one active person and asks the upstreamopen-linksrepo to build a self-contained site undergenerated/site/<id>/.bun run build:sitebuilds every active person intogenerated/site/<id>/and also generates the root landing page atgenerated/site/index.html.- Disabled or archived people are omitted from generated output.
bun run previewruns a full local build once and servesgenerated/site/athttp://127.0.0.1:4173/by default for browser checks.bun run devis a convenience alias for the same preview flow; it does not currently run a watch or hot-reload loop.
The upstream renderer stays canonical for person pages. This repo owns the thin orchestration layer around it plus the root landing page.
Selective-build helpers are now available too:
bun run changed:people -- --base-ref HEAD~1
bun run build:site -- --changed-paths-file .cache/changed-paths.txt --public-origin "https://USER.github.io/open-links-sites"
bun run build:site -- --public-origin "https://links.example.com"
bun run build:site -- --public-origin "https://cdn.example.com/apps/links" --canonical-origin "https://links.example.com/apps/links"
bun run deploy:pages:plan -- --site-dir generated/site --public-origin "https://USER.github.io/open-links-sites"Supported deployment shapes:
- GitHub Pages project path:
--public-origin "https://USER.github.io/open-links-sites" - Custom-domain root deploy:
--public-origin "https://links.example.com" - Arbitrary subpath deploy with separate canonical origin:
--public-origin "https://cdn.example.com/apps/links" --canonical-origin "https://links.example.com/apps/links"
Phase 5 starts tracking the upstream open-links revision explicitly in:
config/upstream-open-links.json
That file is the pinned upstream contract for release operations. The first automation entrypoint is:
bun run sync:upstream -- --root "$PWD"By default it compares the tracked upstream commit to the currently resolved
open-links checkout, updates the tracked state file when upstream has moved,
and prints a stage-based summary. It never pushes by itself.
The scheduled workflow for this lives at:
.github/workflows/upstream-sync.yml
That workflow runs every hour and starts with a cheap git ls-remote
preflight against pRizz/open-links main. If the pinned commit already
matches upstream, it exits before cloning upstream or installing dependencies.
When upstream has moved, it:
- checks out this repo plus upstream
open-links - refreshes
config/upstream-open-links.jsonwhen upstream has moved - runs
bun run checkandbun run validate - commits and pushes one consolidated sync commit to
mainonly if verification passed
The main deploy workflow now also treats that file as build-relevant input:
- push-triggered deploys build against the pinned upstream commit from
config/upstream-open-links.json - nightly scheduled deploys rebuild current
mainagainst that same pinned upstream commit and only deploy whendeploy-manifest.jsondiffers from the live Pages site
Rich-profile cache and follower-history analytics refresh now has its own daily workflow:
.github/workflows/refresh-people-caches.yml
That workflow:
- checks out this repo plus the pinned upstream
open-linkscommit - runs
bun run refresh:people:caches -- --all - runs
bun run check,bun run validate, andbun run release:verify - commits only
people/*/cache/**when cache or follower-history analytics artifacts changed - manually dispatches
deploy.ymlafter a successful cache-only push because pushes made withGITHUB_TOKENdo not trigger downstream workflows
Phase 5 also adds one shared release gate:
bun run release:verify -- --root "$PWD" --public-origin "https://USER.github.io/open-links-sites"
bun run release:verify -- --root "$PWD" --public-origin "https://cdn.example.com/apps/links" --canonical-origin "https://links.example.com/apps/links"That command is now reused by both scheduled upstream sync and the deploy workflow. It runs:
bun run checkbun run validate- a Pages-ready site build for the current mode
- Pages artifact planning
- lightweight smoke checks against
generated/site/
Deployment notes:
publicOrigincontrols emitted asset URLs and the path where the site is actually served.canonicalOriginis optional and defaults topublicOrigin.- Person builds now emit deployment-safe manifests with relative icon paths, so the same output works on GitHub Pages, custom domains, and arbitrary reverse-proxied subpaths.
Operational posture for v1:
- relevant release workflows share the same non-overlap concurrency group
- failures stop before publish and surface in concise stage-based summaries
- recovery is fix-forward on
main; no automatic rollback is attempted