OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / repo.js

342 lines · javascript ref:
/** pgh repo [name] — list/show/info/branches/tags + invite/join + follower mode */

import { PghPeerClient } from '../peer-client.js'

export const usage = `pgh repo — browse repos served by the daemon

  pgh repo                                 List repos
  pgh repo list                            List repos (detailed)
  pgh repo <name>                          Show one repo
  pgh repo info <name>
  pgh repo branches <name>
  pgh repo tags <name>
  pgh repo create <name> [--branch <b>]    Host a new bare repo (default: main)
  pgh repo scan                            Rescan the repos dir

  Per-repo P2P pairing (issue #35):
  pgh repo invite <name> [--role reader|contributor|maintainer|admin]
  pgh repo join <invitation>

  Follower mode:
  pgh repo follow-payload <name>
  pgh repo follow <invitation>
  pgh repo unfollow <sourceServerKey> <name>
  pgh repo follows

  Per-repo autobase = push authority (gip):
  pgh repo writers <name>                  List writers
  pgh repo grant-writer <name> --peer <url> [--role owner|writer]
                                           Admin: grant a peer push authority
  pgh repo backfill-visibility [--dry-run] Mark EVERY existing repo public on this
                                           peer. Run once per peer BEFORE enabling
                                           read-gate enforcement, or every repo
                                           predating the marker goes unserved.
  pgh repo visibility <name>               Show whether THIS peer serves the repo
  pgh repo visibility <name> public        Serve it (writes the export-ok marker)
  pgh repo visibility <name> private       Stop serving it here. Admin.
                                           BEST-EFFORT, not a retraction: bundles
                                           already published stay in the overlay
                                           and peers holding the discoveryKey keep
                                           it. The marker is LOCAL — run on each
                                           peer.
  pgh repo retire <name>                   Tombstone a repo: stop listing/serving/
                                           re-creating it fleet-wide. Bytes are
                                           NOT removed — remove ~/git-repos/
                                           <name>.git by hand on each peer AFTER
                                           the tombstone has replicated. Admin,
                                           primary only.
  pgh repo unretire <name>                 Undo a retire (a later op wins).
  pgh repo list-retired                    Show the replicated tombstone set.
  pgh repo set-head <name> <branch>        Set the repo's DEFAULT BRANCH fleet-wide
                                           (replicated; admin; run on the tracker
                                           owner). Accepts main or
                                           refs/heads/main.
  pgh repo local-key <name>                Print this daemon's tracker local key
  pgh repo revoke <name> <userId>          Revoke a writer
  pgh repo resync <name>                   Heal a stuck tracker: clean re-join in
                                           a fresh corestore + P2P doorbell
                                           auto-admission (no url)`

// A repo name must start alphanumeric and stay within git-safe punctuation —
// same shape pear-git-v3 enforces in lib/repo-name.js.
const VALID_REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/

// Values that mean "a variable was stringified", never a repo someone meant.
// `undefined` is not hypothetical: there is one on the live fleet.
const SENTINELS = new Set(['undefined', 'null', 'nan', 'false', 'true', '[object object]'])

export async function run (client, args) {
  const sub = args[0]

  // No args → list all repos
  if (!sub) {
    const repos = await client.call('list-repos', {})
    return Array.isArray(repos) ? repos : repos.repos || repos
  }

  // Explicit subcommands
  if (sub === 'list' || sub === 'ls') {
    const repos = await client.call('list-repos-detailed', {})
    return Array.isArray(repos) ? repos : repos.repos || repos
  }

  if (sub === 'info') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo info <name>')
    return client.call('repo-info', { name })
  }

  if (sub === 'branches') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo branches <name>')
    return client.call('repo-branches', { name })
  }

  if (sub === 'tags') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo tags <name>')
    return client.call('repo-tags', { name })
  }

  // Default branch. HEAD used to be per-backend, so nodes disagreed and the
  // public saw whichever the load balancer hit; it is replicated now, but a
  // repo whose nodes already disagree gets seeded by whichever writer
  // reconciles first. This makes that a deliberate choice rather than a race.
  // Retirement. Deleting a bare repo by hand does NOT stick: four paths
  // resurrect it (cross-peer re-pull, boot rescan, directory watcher, residual
  // references in issue-registry-index.json / tracker autobases). The tombstone
  // must replicate to every peer BEFORE anyone removes bytes. See
  // pear-git-v3/lib/services/retired-repos.js, which never deletes anything and
  // is explicit that removing bytes is a manual per-peer operator action.
  // visibility — read or set whether THIS peer serves a repo.
  //
  // Deliberately not `--public/--private` flags: the two directions are not
  // symmetric. public→private stops future serving but cannot retract bundles
  // already in the overlay, nor un-tell peers a discoveryKey. The daemon
  // returns a warning saying so; print it rather than swallowing it, or an
  // operator will believe a repo was made secret when it was only unlisted.
  if (sub === 'visibility') {
    const name = args[1]
    const value = args[2]
    if (!name) throw new Error('usage: pgh repo visibility <name> [public|private]')
    if (value && value !== 'public' && value !== 'private') {
      throw new Error("usage: pgh repo visibility <name> [public|private] — got '" + value + "'")
    }
    // No value → query. The marker is LOCAL, so this answers for THIS peer
    // only; run it on each peer to see the whole picture.
    return client.call('repo-visibility', value ? { name, visibility: value } : { name })
  }

  // backfill-visibility — mark every existing repo public on THIS peer.
  //
  // Runs daemon-side, not from a script: `pgh.stage.ignore` lists `scripts/`,
  // so pear-git-v3's backfill script never ships to a peer installed via
  // `pgh app install` — it would be missing on exactly the machines that must
  // run it before enforcement. Idempotent; --dry-run reports without writing.
  if (sub === 'backfill-visibility') {
    const dryRun = args.includes('--dry-run') || args.includes('-n')
    return client.call('repo-backfill-visibility', { dryRun })
  }

  if (sub === 'retire') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo retire <name>')
    return client.call('retire-repo', { name })
  }

  if (sub === 'unretire') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo unretire <name>')
    return client.call('unretire-repo', { name })
  }

  if (sub === 'list-retired' || sub === 'retired') {
    return client.call('list-retired-repos', {})
  }

  if (sub === 'set-head') {
    const name = args[1]
    const ref = args[2]
    if (!name || !ref) throw new Error('usage: pgh repo set-head <name> <branch>')
    return client.call('repo-set-head', { name, ref })
  }

  // Host a new bare repo in one call: init --bare + symbolic-ref HEAD + scan +
  // serve, all daemon-side. Must be matched BEFORE the bare-name fallthrough at
  // the bottom, or `create` would be read as a repo name to show.
  if (sub === 'create') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo create <name> [--branch <branch>]')
    const bi = args.indexOf('--branch')
    const branch = (bi !== -1 && args[bi + 1]) || 'main'
    return client.call('create-repo', { name, branch })
  }

  if (sub === 'scan') {
    return client.call('scan-repos', {})
  }

  // ── Per-repo-autobase invite / join (issue #35) ──
  //
  // A repo tracker is its own autobase, so a peer that joins ONE repo
  // replicates only that repo. Mirrors `pgh org invite|join`.

  if (sub === 'invite') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo invite <name> [--role reader|contributor|maintainer|admin]')
    const ri = args.indexOf('--role')
    const role = (ri !== -1 && args[ri + 1]) || 'reader'
    // → { invitation } — a blind-pairing invite code (the whole UX).
    return client.call('create-repo-invite', { name, role })
  }

  if (sub === 'join') {
    const invitation = args[1]
    if (!invitation) throw new Error('usage: pgh repo join <invitation>')
    // One-step P2P join: the daemon pairs over the swarm and, for
    // contributor+, enrolls us as a writer DURING pairing. No owner URL,
    // no HTTP enroll call — the invite code is the whole UX.
    return client.call('join-repo', { invitation })
  }

  // ── Follower-mode subcommands ───────────────────────────────────
  //
  //   pgh repo follow-payload <name>     emit follow-payload for local repo
  //                                       (source side; operator pastes the
  //                                       URL/payload elsewhere)
  //   pgh repo follow <token>            start following a remote repo
  //   pgh repo unfollow <srvKey> <name>  stop following
  //   pgh repo follows                   list active follows

  if (sub === 'follow-payload') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo follow-payload <name>')
    return client.call('repo-follow-payload', { name })
  }

  if (sub === 'follow') {
    const token = args[1]
    if (!token) {
      throw new Error(
        'usage: pgh repo follow <payload-b64 | punch+follow://srvKey/repo/name>'
      )
    }
    if (token.startsWith('punch+follow://')) {
      return client.call('follow-repo', { url: token })
    }
    return client.call('follow-repo', { payload: token })
  }

  if (sub === 'unfollow') {
    const sourceServerKey = args[1]
    const name = args[2]
    if (!sourceServerKey || !name) {
      throw new Error('usage: pgh repo unfollow <sourceServerKey> <name>')
    }
    return client.call('unfollow-repo', { sourceServerKey, name })
  }

  if (sub === 'follows') {
    return client.call('list-repo-follows', {})
  }

  // gip-overlay authority (P4/P6): the per-repo autobase writer set IS the
  // push authority. Grant = invite/join (above); these expose list + revoke.
  //   pgh repo writers <name>          list push-authorized writers
  //   pgh repo revoke  <name> <userId> admin: remove a writer (future ops dropped)
  if (sub === 'writers') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo writers <name>')
    return client.call('repo-list-writers', { name })
  }

  // Surface this daemon's own tracker local key for a repo — the key an
  // owner needs to grant it write authority (the other side of grant-writer).
  if (sub === 'local-key') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo local-key <name>')
    return client.call('repo-local-key', { name })
  }

  // Admin: grant a peer push authority on a repo by adding its tracker local
  // key to the per-repo autobase writer set. Pulls the peer's local key from
  // its daemon (repo-local-key over the swarm), then addWriters it here. This
  // is what flips a peer that mounted the repo read-only (org-resource path)
  // into a real writer — no re-join. role 'owner' = indexer (offline-tolerant
  // co-equal writer), 'writer' = non-indexer (default).
  if (sub === 'grant-writer') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo grant-writer <name> --peer <url> [--role owner|writer]')
    const pi = args.indexOf('--peer')
    const peer = pi !== -1 ? args[pi + 1] : null
    if (!peer) throw new Error('grant-writer requires --peer <url> (the peer daemon to grant)')
    const ri = args.indexOf('--role')
    const role = (ri !== -1 && args[ri + 1]) || 'writer'
    const ui = args.indexOf('--user')
    const userOverride = ui !== -1 ? args[ui + 1] : null

    // 1. Pull the peer's tracker local key (+ its identity) from its daemon.
    const pc = await PghPeerClient.connect({ url: peer })
    let peerKey
    try {
      peerKey = await pc.call('repo-local-key', { name })
    } finally {
      await pc.close()
    }
    if (!peerKey || !peerKey.localKey) {
      throw new Error('peer did not return a tracker local key for "' + name + '" (is the repo mounted there?)')
    }
    const userId = userOverride || peerKey.identity || peerKey.localKey

    // 2. addWriter the peer's local key on THIS (owner) daemon.
    return client.call('repo-add-writer', { name, userId, writerKey: peerKey.localKey, role })
  }

  // Clean RE-JOIN of a stuck/frozen per-repo tracker into a FRESH corestore
  // (repo-resync RPC → forceResync). Recovers a standalone peer whose tracker
  // can't reconcile a group manifest migration (crash-loops or silently freezes)
  // — a fresh namespace does NOT heal it (same primaryKey-derived local writer),
  // only a fresh corestore does. The daemon AUTO-ADMITS the re-joined key via the
  // P2P doorbell (an optimistic request-writer op vouched by this peer's old
  // writer key; the owner admits on apply) — no url, no HTTP, fully over
  // hyperswarm. One command = heal + participate.
  if (sub === 'resync') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh repo resync <name>')
    return client.call('repo-resync', { repo: name })
  }

  if (sub === 'revoke') {
    const name = args[1]
    const userId = args[2]
    if (!name || !userId) throw new Error('usage: pgh repo revoke <name> <userId>')
    return client.call('repo-revoke-writer', { name, userId })
  }

  // Default: treat the first arg as a repo NAME → show it.
  //
  // GUARDED, because this path has a side effect it does not look like it has:
  // repo-info resolves through getForRepo(), which mints a per-repo tracker on
  // demand, and RefReconcilerManager.ensureFor() runs `git init --bare` when
  // the path is missing. So a READ materializes a repo. Typing
  // `pgh repo --help` created a bare repo called `--help`; 16 empty repos on
  // the live fleet were produced this way, named after flags (`--help`),
  // subcommand words (`list`, `create`, `new`, `help`, `comment`), orgs and
  // projects that were never repos, and one literal `undefined`.
  //
  // The daemon should not create on read either — that is the deeper fix, in
  // repo-info — but the CLI has no business forwarding these in the first place.
  if (sub.startsWith('-')) {
    // A flag reaching here means an unknown option, not a repo called "--help".
    throw new Error(usage)
  }
  if (SENTINELS.has(sub.toLowerCase())) {
    throw new Error(`refusing to treat "${sub}" as a repo name — it looks like a ` +
      'stringified value, not something you meant to type.\n\n' + usage)
  }
  if (!VALID_REPO_NAME.test(sub)) {
    throw new Error(`invalid repo name: ${JSON.stringify(sub)}\n\n` + usage)
  }
  return client.call('repo-info', { name: sub })
}