Skip to content

tools/archon-cli/lib/install.mjs

Source location: docs/source-files/tools/archon-cli/lib/install.mjs — this page is a rendered mirror; the file is the source of truth.

install.mjs
js
// lib/install.mjs — implements `archon install [target-dir]`. Installs Archon
// into a fresh (or forced) project by fetching manifest.json and every file
// it lists, verifying sha256, and writing atomically.
import { promises as fs } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'

import {
  fetchManifest,
  resolveBaseUrl,
  fetchAndVerify,
  writeFileSafe,
  pathExists,
  formatBytes,
  flattenFiles,
} from './manifest.mjs'
import { parseFlags } from './common.mjs'

export async function runInstall({ args }) {
  const { flags, positional } = parseFlags(args)
  const targetDir = path.resolve(positional[0] || process.cwd())
  const force = Boolean(flags.force)
  const dryRun = Boolean(flags['dry-run'])
  const yes = Boolean(flags.yes || flags.y)
  const includeOptional = parseModuleFlag(flags)
  const excludeOptional = parseWithoutFlag(flags)

  console.log(`[archon install] target: ${targetDir}`)

  if (await pathExists(path.join(targetDir, '.archon', 'soul.md'))) {
    if (!force) {
      throw new Error(
        'This project already has Archon installed (.archon/soul.md exists). Use `archon update` or pass --force to re-install.',
      )
    }
    console.log('[archon install] --force supplied; existing .archon will be overwritten (backup to .archon-backup-<timestamp>/).')
  }

  await fs.mkdir(targetDir, { recursive: true })

  const baseUrl = resolveBaseUrl({ flags })
  console.log(`[archon install] fetching manifest from ${baseUrl}/manifest.json …`)
  const manifest = await fetchManifest({ baseUrl })
  console.log(`[archon install] manifest v${manifest.version} — ${manifest.totals.files} files across ${manifest.totals.modules} modules`)

  const selectedModules = await pickModules({ manifest, includeOptional, excludeOptional, yes })
  const files = flattenFiles(manifest, { moduleIds: selectedModules })

  const totalBytes = files.reduce((s, f) => s + f.bytes, 0)
  console.log(`[archon install] plan: ${files.length} files (${formatBytes(totalBytes)}) from ${selectedModules.size} modules`)

  if (dryRun) {
    console.log('[archon install] dry-run — no files written.')
    return
  }

  console.log('[archon install] downloading and verifying …')
  const buffers = new Map()
  let done = 0
  for (const f of files) {
    const buf = await fetchAndVerify(f)
    buffers.set(f.path, buf)
    done += 1
    if (done % 10 === 0 || done === files.length) {
      process.stdout.write(`\r[archon install] verified ${done}/${files.length}`)
    }
  }
  process.stdout.write('\n')

  let backupRoot = null
  if (force) {
    backupRoot = path.join(targetDir, `.archon-backup-${isoStamp()}`)
    await fs.mkdir(backupRoot, { recursive: true })
    console.log(`[archon install] backups will be written to ${path.relative(targetDir, backupRoot)}`)
  }

  console.log('[archon install] writing files …')
  for (const f of files) {
    await writeFileSafe({
      projectRoot: targetDir,
      relPath: f.path,
      buf: buffers.get(f.path),
      backupRoot,
    })
  }

  await seedRuntimeLedgers({ projectRoot: targetDir, manifest })
  await logInstall({
    projectRoot: targetDir,
    manifest,
    selectedModules,
    filesWritten: files.length,
  })

  console.log('')
  console.log(`[archon install] Done. Archon v${manifest.version} installed into ${targetDir}`)
  console.log('[archon install] Next steps: open the project, say "hi archon" to your agent, or see https://aaep.site/setup/quickstart')
}

function parseModuleFlag(flags) {
  const raw = flags['with']
  if (!raw || raw === true) return null
  if (raw === 'all') return 'all'
  if (raw === 'none') return new Set()
  return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))
}

function parseWithoutFlag(flags) {
  const raw = flags['without']
  if (!raw || raw === true) return null
  return new Set(String(raw).split(',').map((s) => s.trim()).filter(Boolean))
}

async function pickModules({ manifest, includeOptional, excludeOptional, yes }) {
  const chosen = new Set()
  const optionals = []
  for (const mod of manifest.modules) {
    if (mod.required) {
      chosen.add(mod.id)
    } else {
      optionals.push(mod)
    }
  }

  if (includeOptional === 'all') {
    for (const m of optionals) chosen.add(m.id)
  } else if (includeOptional instanceof Set) {
    for (const id of includeOptional) chosen.add(id)
  } else if (yes) {
    // no prompt, no --with: default = required only
  } else {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
    try {
      for (const m of optionals) {
        const answer = await ask(rl, `  Include optional module "${m.id}" (${m.file_count} files)? ${m.title}\n  [y/N] `)
        if (answer.toLowerCase().startsWith('y')) chosen.add(m.id)
      }
    } finally {
      rl.close()
    }
  }

  if (excludeOptional instanceof Set) {
    const requiredIds = new Set(manifest.modules.filter((m) => m.required).map((m) => m.id))
    for (const id of excludeOptional) {
      if (requiredIds.has(id)) {
        console.warn(`[archon install] --without=${id} ignored (required module).`)
        continue
      }
      chosen.delete(id)
    }
  }

  return chosen
}

function ask(rl, question) {
  return new Promise((resolve) => rl.question(question, resolve))
}

async function seedRuntimeLedgers({ projectRoot, manifest }) {
  const headers = {
    'manifest.md': '# Project Manifest\n\n_Identity, tech stack, decision index. Edit this as your project evolves._\n',
    'drift.md': '# Drift Log\n\n_One entry per governance event. Append-only._\n',
    'debt.md': '# Debt Log\n\n_Known technical debt. One line per item; details under debt/items/._\n',
    'memos.md': '# Memos Index\n\n_Evaluations, decisions-in-flight, context captures. Details under memos/records/._\n',
    'signs.md': '# Signs Table\n\n_Trigger-indexed reasoning capsules. One row per sign._\n',
    'decisions.md': '# Decision Index\n\n_ADR-style headers. Details inline or linked._\n',
  }
  for (const [name, body] of Object.entries(headers)) {
    const abs = path.join(projectRoot, '.archon', name)
    if (!(await pathExists(abs))) {
      await fs.mkdir(path.dirname(abs), { recursive: true })
      await fs.writeFile(abs, body)
    }
  }
  const dirs = ['.archon/drift/records', '.archon/debt/items', '.archon/memos/records']
  for (const d of dirs) {
    const abs = path.join(projectRoot, d)
    await fs.mkdir(abs, { recursive: true })
    const keep = path.join(abs, '.gitkeep')
    if (!(await pathExists(keep))) await fs.writeFile(keep, '')
  }
}

async function logInstall({ projectRoot, manifest, selectedModules, filesWritten }) {
  const stamp = isoStamp()
  const drift = path.join(projectRoot, '.archon', 'drift.md')
  const entry =
    `\n## install — Archon v${manifest.version} — ${stamp}\n\n` +
    `- Agent: archon-cli (install)\n` +
    `- Modules: ${[...selectedModules].sort().join(', ')}\n` +
    `- Files written: ${filesWritten}\n` +
    `- Source: ${manifest.base_url}/manifest.json (sha256-verified)\n`
  await fs.appendFile(drift, entry)
}

function isoStamp() {
  return new Date().toISOString().replace(/[:.]/g, '-')
}

Released under the Apache-2.0 License.