OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / cli.js

264 lines · javascript ref:
/**
 * pgh CLI — single command dispatcher for the entire pgh stack.
 *
 * Two flavors of commands:
 *   - Pure-local (init, store, publish, releases, app, seed, names,
 *     user, git-passthrough): no daemon client; operate on filesystem
 *     + local registries.
 *   - Daemon-RPC (status, dump-keys, repo, issue, org, profile,
 *     search, deploy, label, admin, config): receive a hyper-svc
 *     Client connected to the named pgh-managed app's UDS (default:
 *     pear-git-v3) via pgh-sidecar's app-lookup.
 *
 * Architecture: one CLI binary, one set of commands. Apps run as
 * pgh-managed daemons; pgh CLI is the only client. Phase 2 of the
 * consolidation folded pear-git-v3's previous duplicate `bin/pgh` +
 * `lib/pgh/commands/` into this dispatcher.
 */

import process from '#node:process'
import { formatOutput } from './output.js'
import { daemonClient } from './daemon-client.js'

const COMMANDS = {
  // Pure-local (no daemon)
  init: () => import('./commands/init.js'),
  store: () => import('./commands/store.js'),
  publish: () => import('./commands/publish.js'),
  releases: () => import('./commands/releases.js'),
  app: () => import('./commands/app.js'),
  seed: () => import('./commands/seed.js'),
  names: () => import('./commands/names.js'),
  claim: () => import('./commands/names.js').then(m => ({ run: m.runClaim, usage: m.usage })),
  release: () => import('./commands/names.js').then(m => ({ run: m.runRelease, usage: m.usage })),
  user: () => import('./commands/user.js'),
  overlay: () => import('./commands/overlay.js'),

  // Mixed (some subcommands talk to daemon, some don't — handled inside)
  identity: () => import('./commands/identity.js'),
  device: () => import('./commands/device.js'),
  peer: () => import('./commands/peer.js'),

  // Daemon-RPC (auto-connect to pear-git-v3 daemon, override with --app)
  status: () => import('./commands/status.js'),
  'dump-keys': () => import('./commands/dump-keys.js'),
  search: () => import('./commands/search.js'),
  profile: () => import('./commands/profile.js'),
  token: () => import('./commands/token.js'),
  attach: () => import('./commands/attach.js'),
  deploy: () => import('./commands/deploy.js'),
  label: () => import('./commands/label.js'),
  'ssh-key': () => import('./commands/ssh-key.js'),
  admin: () => import('./commands/admin.js'),
  repo: () => import('./commands/repo.js'),
  issue: () => import('./commands/issue.js'),
  org: () => import('./commands/org.js'),
  config: () => import('./commands/config.js'),

  // Git passthrough (pure local; commands auto-detect pear remote)
  commit: () => import('./commands/commit.js'),
  clone: () => import('./commands/clone.js'),
  pull: () => import('./commands/pull.js'),
  push: () => import('./commands/push.js'),
  remote: () => import('./commands/remote.js')
}

// Commands that auto-receive a daemon client. Others get null.
// `clone` is here too: clone.js calls get-repo to resolve the repo's
// locally-materialized bare path before cloning from it.
const NEEDS_DAEMON = new Set([
  'status', 'dump-keys', 'search', 'profile', 'token', 'attach', 'deploy', 'label',
  'ssh-key', 'admin', 'repo', 'issue', 'org', 'config', 'clone'
])

// Canonical pgh: daemon commands target the canonical pear-git-v3 daemon by
// default (matches the help text + the post-cutover canonical stack). Override
// with --app or PGH_DEFAULT_APP (e.g. PGH_DEFAULT_APP=pear-git-v3-experimental
// for the experimental daemon).
const DEFAULT_APP = process.env.PGH_DEFAULT_APP || 'pear-git-v3'

// Git-passthrough commands forward their args (including --help/-h) to git, so
// `pgh push main --help` should reach git. We only show pgh's own usage for
// these when help is the FIRST token (`pgh push --help`).
const PASSTHROUGH = new Set(['commit', 'push', 'pull', 'remote', 'clone'])

export async function run (argv) {
  const { globals, command, args } = parseArgv(argv)

  if (!command || command === 'help' || command === '--help' || command === '-h') {
    printUsage()
    return
  }

  const loader = COMMANDS[command]
  if (!loader) {
    console.error('pgh: unknown command "' + command + '"')
    console.error('Run "pgh help" for usage.')
    process.exit(1)
  }

  const mod = await loader()

  // Per-command help. Handled BEFORE the daemon connection below — otherwise
  // `pgh issue --help` (a NEEDS_DAEMON command) would fail with a connection
  // error instead of printing usage when no daemon is reachable.
  if (wantsHelp(command, args)) {
    printCommandUsage(command, mod)
    return
  }

  // Past the help check, `--` has done its job — hand commands the values.
  const cmdArgs = stripOptionTerminator(command, args)

  const appName = globals.app || DEFAULT_APP
  let client = null
  if (NEEDS_DAEMON.has(command)) {
    try { client = await daemonClient(appName) }
    catch (err) {
      console.error('pgh: ' + err.message)
      if (globals.debug) console.error(err.stack)
      process.exit(1)
    }
  }

  try {
    const result = await mod.run(client, cmdArgs, globals)
    if (result !== undefined && result !== null) {
      console.log(formatOutput(result, globals.format))
    }
  } catch (err) {
    console.error('pgh: ' + (err.message || err))
    if (globals.debug) console.error(err.stack)
    process.exit(1)
  } finally {
    if (client) try { await client.close() } catch {}
  }
}

// True when the user is asking for a command's usage rather than running it.
// For passthrough commands we only treat a LEADING --help/-h/help as ours
// (anything later is forwarded to git); for everything else, --help/-h
// anywhere triggers usage.
export function wantsHelp (command, args) {
  if (args[0] === 'help' || args[0] === '--help' || args[0] === '-h') return true
  if (PASSTHROUGH.has(command)) return false
  // `--` ends option parsing (POSIX convention): everything after it is a
  // VALUE, never a flag. Without this there is no way to name a thing that
  // looks like a flag — and the forge really does contain a repo called
  // `--help`, created back when a read minted repos. `pgh repo retire --help`
  // rightly prints usage; `pgh repo retire -- --help` retires that repo.
  const end = args.indexOf('--')
  const flagArgs = end === -1 ? args : args.slice(0, end)
  return flagArgs.includes('--help') || flagArgs.includes('-h')
}

/**
 * Drop the first standalone `--` so commands receive the real values.
 *
 * NOT applied to passthrough commands: those forward argv to git, where `--`
 * is meaningful (`git checkout -- <path>`) and must survive untouched.
 */
export function stripOptionTerminator (command, args) {
  if (PASSTHROUGH.has(command)) return args
  const i = args.indexOf('--')
  return i === -1 ? args : args.slice(0, i).concat(args.slice(i + 1))
}

function printCommandUsage (command, mod) {
  const u = typeof mod.usage === 'function' ? mod.usage() : mod.usage
  if (u) {
    console.log(String(u).replace(/\n+$/, ''))
    return
  }
  console.log('pgh ' + command + ': no detailed help available.')
  console.log('Run "pgh help" for the full command list.')
}

function parseArgv (argv) {
  const globals = { format: 'table', debug: false }
  const rest = []
  let sawCommand = false

  // Globals are ONLY consumed before the command. Once we see a non-flag
  // token (the command), any later --json / --format / --debug is passed
  // through as an arg to the subcommand. Prevents `peer call ... --json
  // '{...}'` from having its --json swallowed as a format flag.
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (!sawCommand) {
      if (a === '--format' && argv[i + 1]) { globals.format = argv[++i]; continue }
      if (a === '--json') { globals.format = 'json'; continue }
      if (a === '--debug') { globals.debug = true; continue }
      if (a === '--app' && argv[i + 1]) { globals.app = argv[++i]; continue }
      if (!a.startsWith('--')) sawCommand = true
    }
    rest.push(a)
  }

  return { globals, command: rest[0] || null, args: rest.slice(1) }
}

function printUsage () {
  console.log(`pgh — P2P content-addressed package manager

Usage: pgh [--json] [--debug] <command> [args...]

Commands:
  init [--bootstrap <url>]           One-time setup on this machine
  app install|update|remove|list     App install/update/uninstall
  app start|stop|restart|status      Process lifecycle (supervisor-managed)
  app log <name> [-f] [--lines N]    Tail app.log
  app autostart <name> on|off        Mark app for inclusion in 'app boot'
  app boot | shutdown                Start/stop all autostart apps in dep order
  app adopt <path> [--name <name>]   Register a source-tree dir as an app
  app gc <name>                      Run 'git gc --prune=now' on each bare repo in the app's repos dir
  app heal [name]                    fsck every bare repo; refetch or force-reclone any with missing objects. No name = all apps.
  store list|size|gc|pin|unpin       Tree store management
  publish [dir]                      Compute lockfile + record release
  seed <dir> [--topic <hex>]         Serve a published app on Hyperswarm
  releases list|latest|show          Inspect the local ReleaseRegistry
  identity show                      Print root/device pubkey + chain depth
  device invite [--label <name>]     Generate link-invite for a second device
  device join <inviteHex>            Accept an invite, write linked-device
  device list --peer <url>           List devices linked to your identity on <url>
  device revoke <pubkey> --peer <url>  Revoke a linked device on <url>
  device label <pubkey> <name> --peer <url>  Rename a linked device on <url>
  peer call <url> <method> [--param k=v] [--json <JSON>]
                                     Envelope-signed raw RPC to a remote daemon
  claim <name> [--description "..."] Register a flat app name (NameRegistry)
  release <name>                     Tombstone a name you own
  names list|resolve|info            Inspect the NameRegistry

Daemon commands (target an app's daemon; default pear-git-v3, override --app <name>):
  status                             Daemon status (mounts, swarm, counts)
  repo list|show|files|commits|...   Browse repos served by the daemon
  repo invite|join                   Repo-scoped P2P pairing (peer mode)
  issue <repo>|--org <n> [--create]   List/create/view issues (--close|--comment|...)
  org list|show|create|delete        Orgs and membership
  org invite|join                    Org-scoped P2P pairing (peer mode)
  org add-resource|remove-resource   Attach/detach a repo to/from an org
  profile view|set                   Identity profile
  search repo|code <query>           Search repos / code
  label ...                          Issue labels
  deploy setup|trigger|status|log    CI/CD config + release log
  admin ...                          Admin operations
  config list|set                    Local CLI config
  dump-keys                          Bootstrap keys (UDS only)

Git passthrough (auto-detects a pear:// / punch:// remote):
  clone <repo>                       Clone a daemon-served repo
  commit | push | pull               git passthrough over a pear remote
  remote add|rm|list|migrate-origins Manage pear remotes (NameRegistry petnames)

Other:
  user list|promote|revoke --app <n> Manage users on an app's daemon
  overlay ...                        hyper-overlay file routing
  help | --help | -h                 This message

Flags:
  --app <name>                       Target a specific app's daemon (default pear-git-v3)
  --json                             Output as JSON
  --debug                            Print stack traces on error
  --format table|json|plain          Output format`)
}