OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / export-official-umbrel-app.mjs

211 lines · javascript ref:
#!/usr/bin/env node

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')

const usage = `
Usage:
  node scripts/export-official-umbrel-app.mjs --target <umbrel-apps/blindspark> [options]

Options:
  --target <path>             Destination app directory in a getumbrel/umbrel-apps checkout
  --submission-url <url>      Replace the manifest submission field with the real PR URL
  --allow-placeholder         Permit the PENDING submission URL for the pre-PR export
  --check                     Verify the target is already in sync without writing
`

const args = parseArgs(process.argv.slice(2))
const target = args.target ? path.resolve(args.target) : ''
const checkOnly = Boolean(args.check)
const allowPlaceholder = Boolean(args.allowPlaceholder)
const submissionUrl = args.submissionUrl || ''

if (!target) die(usage.trim())
if (path.basename(target) !== 'blindspark') {
  die(`Refusing to export to ${target}; target directory must be named "blindspark".`)
}

if (submissionUrl && !/^https:\/\/github\.com\/getumbrel\/umbrel-apps\/pull\/[1-9][0-9]*$/.test(submissionUrl)) {
  die('--submission-url must be a getumbrel/umbrel-apps pull request URL.')
}

const manifest = normalizeManifest(readSource('umbrel-app.yml'))
const compose = readSource('docker-compose.yml')
const expected = new Map([
  ['umbrel-app.yml', manifest],
  ['docker-compose.yml', compose],
  ['data/.gitkeep', '']
])

assertSafeTargetRoot(target)
assertSafeTargetPaths(target, expected.keys())

if (!submissionUrl && !allowPlaceholder && manifest.includes('/pull/PENDING')) {
  die('Official export still has the PENDING submission URL. Pass --submission-url <PR URL> after opening the PR, or --allow-placeholder for the first pre-PR export.')
}

const extras = listExistingFiles(target).filter((file) => !expected.has(file))
if (extras.length > 0) {
  die('Official Umbrel app directory contains files that should not be in the PR:\n' + extras.map((file) => `- ${file}`).join('\n'))
}

const drift = []
for (const [rel, content] of expected) {
  const file = path.join(target, rel)
  if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === content) continue
  drift.push(rel)
}

if (checkOnly) {
  if (drift.length > 0) {
    die('Official Umbrel export is out of sync:\n' + drift.map((file) => `- ${file}`).join('\n'))
  }
  console.log(`Official Umbrel export is in sync at ${target}.`)
  process.exit(0)
}

for (const [rel, content] of expected) {
  const file = path.join(target, rel)
  fs.mkdirSync(path.dirname(file), { recursive: true })
  fs.writeFileSync(file, content)
}

console.log(`Official Umbrel app package exported to ${target}.`)
if (drift.length > 0) {
  for (const file of drift) console.log(`- wrote ${file}`)
} else {
  console.log('- no file changes needed')
}

function normalizeManifest (input) {
  let text = input
  text = replaceYamlField(text, 'manifestVersion', 'manifestVersion: 1.1')
  text = replaceYamlField(text, 'gallery', 'gallery: []')
  text = replaceYamlField(text, 'releaseNotes', 'releaseNotes: ""')
  if (submissionUrl) {
    text = replaceYamlField(text, 'submission', `submission: ${submissionUrl}`)
  }
  text = text.replace(/^icon:.*\n?/m, '')
  if (!/\n$/.test(text)) text += '\n'
  return text
}

function replaceYamlField (text, key, replacement) {
  const re = new RegExp(`^${escapeRegExp(key)}:.*(?:\\n[ \\t].*)*`, 'm')
  if (!re.test(text)) die(`Could not find ${key} in umbrel-app.yml.`)
  return text.replace(re, replacement)
}

function readSource (file) {
  return fs.readFileSync(path.join(repoRoot, 'umbrel-app', file), 'utf8')
}

function listExistingFiles (dir) {
  if (!fs.existsSync(dir)) return []
  const out = []
  walk(dir, '')
  return out.sort()

  function walk (abs, rel) {
    for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
      const childAbs = path.join(abs, entry.name)
      const childRel = rel ? `${rel}/${entry.name}` : entry.name
      if (entry.isDirectory()) walk(childAbs, childRel)
      else if (entry.isFile()) out.push(childRel)
      else out.push(childRel)
    }
  }
}

function assertSafeTargetRoot (root) {
  let current = root
  while (!fs.existsSync(current)) {
    const parent = path.dirname(current)
    if (parent === current) {
      die(`Refusing to export to ${root}; no existing parent directory was found.`)
    }
    current = parent
  }

  const stat = fs.lstatSync(current)
  if (stat.isSymbolicLink()) {
    die(`Refusing to export through symlinked official Umbrel path: ${current}`)
  }
  if (!stat.isDirectory()) {
    die(`Refusing to export through non-directory official Umbrel path: ${current}`)
  }
}

function assertSafeTargetPaths (root, relativeFiles) {
  if (fs.existsSync(root)) {
    const stat = fs.lstatSync(root)
    if (stat.isSymbolicLink() || !stat.isDirectory()) {
      die(`Refusing to export to ${root}; target must be a real directory.`)
    }
  }

  for (const rel of relativeFiles) {
    const parts = rel.split('/')
    let current = root
    for (let i = 0; i < parts.length; i++) {
      current = path.join(current, parts[i])
      if (!fs.existsSync(current)) continue
      const stat = fs.lstatSync(current)
      if (stat.isSymbolicLink()) {
        die(`Refusing to export through symlinked official Umbrel path: ${path.relative(root, current)}`)
      }
      const isLeaf = i === parts.length - 1
      if (isLeaf && !stat.isFile()) {
        die(`Refusing to overwrite non-file official Umbrel path: ${path.relative(root, current)}`)
      }
      if (!isLeaf && !stat.isDirectory()) {
        die(`Refusing to export through non-directory official Umbrel path: ${path.relative(root, current)}`)
      }
    }
  }
}

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      console.log(usage.trim())
      process.exit(0)
    }
    if (arg === '--check') {
      out.check = true
      continue
    }
    if (arg === '--allow-placeholder') {
      out.allowPlaceholder = true
      continue
    }
    if (arg === '--target' || arg === '--submission-url') {
      const value = argv[++i]
      if (!value || value.startsWith('--')) die(`Missing value for ${arg}`)
      out[camel(arg.slice(2))] = value
      continue
    }
    die(`Unknown argument: ${arg}`)
  }
  return out
}

function camel (value) {
  return value.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}

function escapeRegExp (value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function die (message) {
  console.error(message)
  process.exit(1)
}