OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / org.js

193 lines · javascript ref:
/** pgh org list|show|create|delete|members|add-member|remove-member|add-resource|remove-resource|invite|join|peers|set-auth */

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

export const usage = `pgh org — orgs and membership

  pgh org list                             List orgs
  pgh org show <name>                      Show one org
  pgh org create <name> [--type group|team]
  pgh org delete <name>
  pgh org members <name>                   List members
  pgh org add-member <name> <userId>
  pgh org remove-member <name> <userId>
  pgh org add-resource <name> <repo>       Attach a repo to the org
  pgh org remove-resource <name> <repo>
  pgh org invite <name> [--role reader|contributor|maintainer|admin]
  pgh org join <invitation>                P2P join via an invite code
  pgh org peers <name>                      Show resource repos + their writers
  pgh org set-auth <name> --peer <url> --level owner|writer|reader
                                           Admin: set a peer's write authority
                                           across ALL of the org's repos`

export async function run (client, args) {
  const sub = args[0]
  if (!sub) throw new Error(usage)

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

  // ── Per-org-autobase invite / join (issue #35) ──
  if (sub === 'invite') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh org 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-org-invite', { name, role })
  }

  if (sub === 'join') {
    const invitation = args[1]
    if (!invitation) throw new Error('usage: pgh org 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-org', { invitation })
  }

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

  if (sub === 'create') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh org create <name> [--type group|team]')
    const typeIdx = args.indexOf('--type')
    const type = (typeIdx !== -1 && args[typeIdx + 1]) || 'group'
    return client.call('create-org', { name, type })
  }

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

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

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

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

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

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

  // P2P pairing: owner mints an invitation; a peer joins the org over the
  // swarm and (for contributor+) is added as a writer during pairing.
  if (sub === 'invite') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh org invite <name> [--role reader|contributor|owner]')
    const roleIdx = args.indexOf('--role')
    const role = (roleIdx !== -1 && args[roleIdx + 1]) || 'reader'
    return client.call('create-org-invite', { name, role })
  }

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

  // See all peers' write authority across the org's repos. For each resource
  // repo, list the per-repo autobase writers (gip push authority). This is the
  // admin's "who can write what" view that the org-resource path lacked.
  if (sub === 'peers') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh org peers <name>')
    const org = await client.call('get-org', { name })
    const resources = Array.isArray(org.resources) && org.resources.length
      ? org.resources
      : Object.keys(org.resourceKeys || {})
    const out = { org: name, members: org.members || [], resources: [] }
    for (const repo of resources) {
      try {
        const w = await client.call('repo-list-writers', { name: repo })
        out.resources.push({ repo, writers: w.writers || [] })
      } catch (err) {
        out.resources.push({ repo, error: err.message })
      }
    }
    return out
  }

  // Admin: set a peer's write authority across EVERY repo in the org, in one
  // shot. For owner/writer, pull the peer's per-repo tracker local key and
  // addWriter it (the peer's read-only org-mount flips to writable). For
  // reader, revoke. This is the unified "set the auth level for a peer" the
  // org layer was missing — it drives the autobase writer-set, not just the
  // git-push ACL.
  if (sub === 'set-auth') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh org set-auth <name> --peer <url> --level owner|writer|reader')
    const pi = args.indexOf('--peer')
    const peer = pi !== -1 ? args[pi + 1] : null
    if (!peer) throw new Error('set-auth requires --peer <url> (the peer daemon)')
    const li = args.indexOf('--level')
    const level = (li !== -1 && args[li + 1]) || 'writer'
    if (!['owner', 'writer', 'reader'].includes(level)) {
      throw new Error('--level must be owner, writer, or reader')
    }

    const org = await client.call('get-org', { name })
    const resources = Array.isArray(org.resources) && org.resources.length
      ? org.resources
      : Object.keys(org.resourceKeys || {})
    if (!resources.length) throw new Error('org "' + name + '" has no resource repos')

    const results = []
    for (const repo of resources) {
      try {
        // Pull the peer's tracker local key + identity for this repo.
        const pc = await PghPeerClient.connect({ url: peer })
        let pk
        try { pk = await pc.call('repo-local-key', { name: repo }) } finally { await pc.close() }
        if (!pk || !pk.localKey) {
          results.push({ repo, error: 'peer has no tracker local key (repo not mounted there?)' })
          continue
        }
        const userId = pk.identity || pk.localKey
        if (level === 'reader') {
          await client.call('repo-revoke-writer', { name: repo, userId })
          results.push({ repo, level, userId, revoked: true })
        } else {
          await client.call('repo-add-writer', { name: repo, userId, writerKey: pk.localKey, role: level })
          results.push({ repo, level, userId, granted: true })
        }
      } catch (err) {
        results.push({ repo, error: err.message })
      }
    }
    return { org: name, peer, level, resources: results }
  }

  throw new Error('unknown org subcommand: ' + sub + '\n' + usage)
}