OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / prepare-release.test.js

486 lines · javascript ref:
import test from 'brittle'
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { EXPECTED_CURRENT_CONSUMERS } from '../../scripts/audit-ecosystem-consumers.mjs'

const DIGEST = 'sha256:' + 'a'.repeat(64)

function runPrepare (argv, script = 'scripts/prepare-release.mjs', extraEnv = {}) {
  return new Promise((resolve) => {
    execFile(process.execPath, [script, ...argv], {
      cwd: process.cwd(),
      env: { PATH: process.env.PATH || '', ...extraEnv },
      timeout: 10000
    }, (err, stdout, stderr) => {
      resolve({
        status: err && typeof err.code === 'number' ? err.code : 0,
        stdout,
        stderr
      })
    })
  })
}

test('prepare-release defaults prerelease channel to none', async (t) => {
  const res = await runPrepare([
    'v9.9.9-beta.1',
    '--image-digest', DIGEST,
    '--check'
  ])

  t.is(res.status, 1, 'future prerelease reports expected drift')
  t.ok(res.stderr.includes('Release surfaces are out of sync:'))
  t.absent(res.stderr.includes('fleet/channels.json'), 'implicit prerelease does not bump fleet channels')
  t.absent(res.stderr.includes('blindspark-umbrel-store'), 'implicit prerelease does not sync the community store checkout')
  t.absent(res.stderr.includes('hiverelay-blindspark'), 'implicit prerelease does not sync community store package files')
})

test('prepare-release local bypass preserves an existing immutable Umbrel pin', async (t) => {
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-preserve-pin-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)

  const composePath = path.join(repo, 'umbrel-app', 'docker-compose.yml')
  const before = await readFile(composePath, 'utf8')
  const immutableRef = before.match(/ghcr\.io\/bigdestiny2\/p2p-hiverelay:[^\s]+/)[0]
  const res = await runPrepare([
    'v9.9.9-beta.1',
    '--channel', 'none',
    '--allow-unpinned-image',
    '--no-umbrel-store',
    '--no-ecosystem-consumers'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 0, res.stderr)
  const after = await readFile(composePath, 'utf8')
  t.ok(after.includes(immutableRef), 'existing immutable Umbrel ref is retained')
  t.ok(res.stdout.includes('is not bound to 9.9.9-beta.1 and is not release-ready'))
  t.absent(after.includes('p2p-hiverelay:9.9.9-beta.1\n'), 'local bypass does not weaken the pin to a tag')
})

test('prepare-release defaults full release channel to both', async (t) => {
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-both-fixture-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)

  const res = await runPrepare([
    'v9.9.9',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--no-ecosystem-consumers'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 0, res.stderr)
  const channels = JSON.parse(await readFile(path.join(repo, 'fleet', 'channels.json'), 'utf8'))
  t.is(channels.canary, 'v9.9.9', 'implicit full release bumps canary')
  t.is(channels.stable, 'v9.9.9', 'implicit full release bumps stable')

  const startosManifest = await readFile(path.join(repo, 'startos', 'manifest.yaml'), 'utf8')
  t.absent(startosManifest.includes('metadata.license'), 'StartOS release-notes block keeps the following key on a new line')
  t.ok(startosManifest.includes('HexOS.\nlicense: apache-2.0'), 'StartOS release-notes block is newline-terminated')

  const truenasManifest = await readFile(path.join(repo, 'truenas-app', 'app.yaml'), 'utf8')
  const truenasImages = await readFile(path.join(repo, 'truenas-app', 'ix_values.yaml'), 'utf8')
  const truenasReadme = await readFile(path.join(repo, 'truenas-app', 'README.md'), 'utf8')
  t.ok(truenasManifest.includes('app_version: 9.9.9'), 'TrueNAS upstream version is synchronized')
  t.ok(truenasManifest.includes('version: 1.0.1'), 'TrueNAS catalog revision is bumped once')
  t.ok(truenasImages.includes('tag: 9.9.9'), 'TrueNAS image tag is synchronized')
  t.ok(truenasReadme.includes('Upstream HiveRelay release: `9.9.9`'), 'TrueNAS README is synchronized')

  const unraidTemplate = await readFile(path.join(repo, 'unraid-app', 'templates', 'blindspark.xml'), 'utf8')
  t.ok(unraidTemplate.includes(`ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9@${DIGEST}`), 'Unraid image digest is synchronized')
  t.ok(unraidTemplate.includes('<Changes>HiveRelay 9.9.9</Changes>'), 'Unraid release metadata is synchronized')

  const zimaCompose = await readFile(path.join(repo, 'zimaos-app', 'Apps', 'Blindspark', 'docker-compose.yml'), 'utf8')
  t.ok(zimaCompose.includes(`ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9@${DIGEST}`), 'ZimaOS image digest is synchronized')
  t.ok(zimaCompose.includes('  version: "9.9.9"'), 'ZimaOS upstream version is synchronized')

  const runtipiConfig = JSON.parse(await readFile(path.join(repo, 'runtipi-app', 'apps', 'blindspark', 'config.json'), 'utf8'))
  const runtipiCompose = await readFile(path.join(repo, 'runtipi-app', 'apps', 'blindspark', 'docker-compose.yml'), 'utf8')
  t.is(runtipiConfig.version, '9.9.9', 'Runtipi upstream version is synchronized')
  t.is(runtipiConfig.tipi_version, 2, 'Runtipi package revision is bumped once')
  t.ok(runtipiCompose.includes(`ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9@${DIGEST}`), 'Runtipi image digest is synchronized')

  const hexos = JSON.parse(await readFile(path.join(repo, 'hexos-app', 'blindspark.json'), 'utf8'))
  t.is(hexos.script.version, '1.0.1', 'HexOS curation revision is bumped once')
  t.is(hexos.script.changeLog, 'Sync Blindspark for HiveRelay 9.9.9', 'HexOS upstream version is synchronized')
})

test('prepare-release cannot move fleet channels for an enabled public gateway release', async (t) => {
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-gateway-fixture-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)
  await writeJson(path.join(repo, 'fleet', 'public-hive-gateway-release.json'), {
    schema: 'hiverelay-public-gateway-release-v1',
    enabled: true,
    releaseTarget: 'v9.9.9'
  })

  const blocked = await runPrepare([
    'v9.9.9',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--no-ecosystem-consumers'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(blocked.status, 1)
  t.ok(blocked.stderr.includes('cannot move fleet channel "both"'))
  t.ok(blocked.stderr.includes('use --channel none'))
  const channels = JSON.parse(await readFile(path.join(repo, 'fleet', 'channels.json'), 'utf8'))
  t.is(channels.canary, 'v0.16.3')
  t.is(channels.stable, 'v0.16.3')
})

test('prepare-release requires sibling ecosystem workspace for stable app-default sync', async (t) => {
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-missing-ecosystem-fixture-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)

  const res = await runPrepare([
    'v9.9.9',
    '--image-digest', DIGEST,
    '--no-umbrel-store'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 1)
  t.ok(res.stderr.includes('Cannot sync ecosystem consumer defaults'))
  t.ok(res.stderr.includes('full sibling workspace not found'))
  t.ok(res.stderr.includes('--no-ecosystem-consumers'))
})

test('prepare-release syncs sibling ecosystem consumer defaults', async (t) => {
  const root = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-ecosystem-fixture-'))
  t.teardown(async () => {
    await rm(root, { recursive: true, force: true })
  })
  const repo = path.join(root, '00-core', 'hiverelay')
  await writeMinimalReleaseFixture(repo)
  await writeEcosystemConsumerFixture(root, '0.16.3')

  const res = await runPrepare([
    'v9.9.9',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--ecosystem-dependency-mode', 'local'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 0, res.stderr)
  t.ok(res.stdout.includes('ecosystem/01-browser/pearbrowser-desktop/package.json'))

  const pearbrowser = JSON.parse(await readFile(path.join(root, '01-browser', 'pearbrowser-desktop', 'package.json'), 'utf8'))
  t.is(pearbrowser.dependencies['p2p-hiverelay'], 'file:../../00-core/hiverelay/packages/core')
  t.is(pearbrowser.dependencies['p2p-hiverelay-client'], 'file:../../00-core/hiverelay/packages/client')
  t.is(pearbrowser.dependencies['p2p-hiverelay-verifier'], 'file:../../00-core/hiverelay/packages/verifier')

  const lock = JSON.parse(await readFile(path.join(root, '01-browser', 'pearbrowser-desktop', 'package-lock.json'), 'utf8'))
  t.is(lock.packages['../../00-core/hiverelay/packages/core'].version, '9.9.9')
  t.is(lock.packages['../../00-core/hiverelay/packages/client'].dependencies['p2p-hiverelay'], '^9.9.9')

  const catalog = await readFile(path.join(root, '01-browser', 'pearbrowser-desktop', 'catalog-source', 'pearbrowser-network.catalog.json'), 'utf8')
  const handover = await readFile(path.join(root, '01-browser', 'pearbrowser-desktop', 'docs', 'HIVERELAY-BACKBONE-HANDOVER.md'), 'utf8')
  t.ok(catalog.includes('"version": "9.9.9"'))
  t.ok(handover.includes('`p2p-hiverelay` `9.9.9`'))
})

test('prepare-release defaults sibling ecosystem consumer checks to npm latest', async (t) => {
  const root = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-ecosystem-latest-fixture-'))
  t.teardown(async () => {
    await rm(root, { recursive: true, force: true })
  })
  const repo = path.join(root, '00-core', 'hiverelay')
  await writeMinimalReleaseFixture(repo)
  await writeEcosystemConsumerFixture(root, '0.16.3')

  const npmLatest = JSON.stringify({
    'p2p-hiverelay': '9.9.9',
    'p2p-hiverelay-client': '9.9.9',
    'p2p-hiverelay-verifier': '9.9.9',
    'p2p-hiveservices': '9.9.9'
  })
  const res = await runPrepare([
    'v9.9.9',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--check'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'), {
    HIVERELAY_NPM_LATEST_JSON: npmLatest
  })

  t.is(res.status, 1, 'check mode reports app default drift')
  t.ok(res.stderr.includes('Ecosystem consumer default sync failed:'))
  t.ok(res.stderr.includes('ecosystem consumer file(s) need default-version sync'))
  t.ok(res.stderr.includes('expected "latest"'))
})

test('prepare-release rejects explicit prerelease channel promotion', async (t) => {
  const res = await runPrepare([
    'v9.9.9-beta.1',
    '--channel', 'canary',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--check'
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('Pre-release v9.9.9-beta.1 cannot promote fleet/app-store channel "canary"'))
})

test('prepare-release syncs the community store for a prerelease with an explicit target', async (t) => {
  // The community Umbrel store must auto-sync on EVERY release, prereleases
  // included, so it never lags the fleet. A prerelease that passes an explicit
  // --umbrel-store target is no longer rejected; it syncs the store (bumps the
  // version and re-pins the image digest) just like a full release.
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-prerelease-store-repo-'))
  const store = await mkdtemp(path.join(tmpdir(), 'hiverelay-prerelease-store-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
    await rm(store, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)
  await writeCommunityStoreFixture(store, '0.16.3')

  const res = await runPrepare([
    'v9.9.9-beta.1',
    '--umbrel-store', store,
    '--image-digest', DIGEST,
    '--no-ecosystem-consumers',
    '--check'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 1, res.stderr)
  t.ok(res.stderr.includes('Release surfaces are out of sync:'))
  t.ok(res.stderr.includes('hiverelay-blindspark/docker-compose.yml'), 'prerelease re-pins the community store compose image')
  t.absent(res.stderr.includes('cannot sync the community Umbrel store'), 'prerelease with explicit store target is no longer rejected')
  t.absent(res.stderr.includes('fleet/channels.json'), 'prerelease still does not bump fleet channels')
})

test('prepare-release skips the community store for an implicit prerelease', async (t) => {
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-prerelease-implicit-repo-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)

  const res = await runPrepare([
    'v9.9.9-beta.1',
    '--image-digest', DIGEST,
    '--no-ecosystem-consumers',
    '--check'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 1, res.stderr)
  t.absent(res.stderr.includes('hiverelay-blindspark'), 'implicit prerelease without --umbrel-store does not sync the community store')
})

test('prepare-release rejects invalid ecosystem dependency mode', async (t) => {
  const res = await runPrepare([
    'v9.9.9',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--ecosystem-dependency-mode', 'floating'
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('Invalid --ecosystem-dependency-mode "floating"'))
})

test('prepare-release rejects secret-looking release notes before metadata sync', async (t) => {
  const res = await runPrepare([
    'v0.16.3',
    '--channel', 'none',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--release-notes', 'Release candidate Authorization: Bearer abcdefghijklmnopqrstuvwxyz',
    '--check'
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('release notes must not expose authorization header'))
  t.absent(res.stderr.includes('Release surfaces are out of sync'))
})

test('prepare-release rejects unsafe release-note control characters before metadata sync', async (t) => {
  const res = await runPrepare([
    'v0.16.3',
    '--channel', 'none',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--release-notes', 'Safe text\u001b[31m',
    '--check'
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('release notes must not contain unsafe control characters'))
  t.absent(res.stderr.includes('Release surfaces are out of sync'))
})

test('prepare-release rejects overbroad release promise claims before metadata sync', async (t) => {
  const res = await runPrepare([
    'v0.16.3',
    '--channel', 'none',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--release-notes', 'Ship AI poker custody as the public release story.',
    '--check'
  ])

  t.is(res.status, 1)
  t.ok(res.stderr.includes('release notes must stay scoped to Core Availability / Blindspark'))
  t.ok(res.stderr.includes('AI/QVAC/Ollama product claim'))
  t.absent(res.stderr.includes('Release surfaces are out of sync'))
})

test('prepare-release removes local-only README status suffix for public releases', async (t) => {
  const repo = await mkdtemp(path.join(tmpdir(), 'hiverelay-release-fixture-'))
  t.teardown(async () => {
    await rm(repo, { recursive: true, force: true })
  })
  await writeMinimalReleaseFixture(repo)

  const res = await runPrepare([
    'v9.9.9',
    '--channel', 'none',
    '--image-digest', DIGEST,
    '--no-umbrel-store',
    '--no-ecosystem-consumers'
  ], path.join(repo, 'scripts', 'prepare-release.mjs'))

  t.is(res.status, 0, res.stderr)
  const readme = await readFile(path.join(repo, 'README.md'), 'utf8')
  t.ok(readme.includes('Status: v9.9.9**'))
  t.absent(readme.includes('packages; main has unreleased upgrades'))
})

async function writeMinimalReleaseFixture (repo) {
  await writeText(path.join(repo, 'scripts', 'prepare-release.mjs'), await readFile('scripts/prepare-release.mjs', 'utf8'))
  await writeText(path.join(repo, 'scripts', 'lib', 'release-promise-scope.mjs'), await readFile('scripts/lib/release-promise-scope.mjs', 'utf8'))
  await writeText(path.join(repo, 'scripts', 'audit-ecosystem-consumers.mjs'), await readFile('scripts/audit-ecosystem-consumers.mjs', 'utf8'))
  await writeText(path.join(repo, 'scripts', 'check-ecosystem-workspace.mjs'), await readFile('scripts/check-ecosystem-workspace.mjs', 'utf8'))
  await writeText(path.join(repo, 'scripts', 'sync-ecosystem-consumers.mjs'), await readFile('scripts/sync-ecosystem-consumers.mjs', 'utf8'))
  await writeJson(path.join(repo, 'package.json'), { name: 'p2p-hiverelay-monorepo', version: '0.16.3' })
  await writeJson(path.join(repo, 'packages', 'core', 'package.json'), { name: 'p2p-hiverelay', version: '0.16.3' })
  await writeJson(path.join(repo, 'packages', 'services', 'package.json'), { name: 'p2p-hiveservices', version: '0.16.3' })
  await writeJson(path.join(repo, 'packages', 'client', 'package.json'), { name: 'p2p-hiverelay-client', version: '0.16.3', dependencies: { 'p2p-hiverelay': '^0.16.3' } })
  await writeJson(path.join(repo, 'packages', 'verifier', 'package.json'), { name: 'p2p-hiverelay-verifier', version: '0.16.3', dependencies: { 'p2p-hiverelay': '^0.16.3' } })
  await writeJson(path.join(repo, 'package-lock.json'), {
    name: 'p2p-hiverelay-monorepo',
    version: '0.16.3',
    lockfileVersion: 3,
    packages: {
      '': { version: '0.16.3' },
      'packages/core': { version: '0.16.3' },
      'packages/services': { version: '0.16.3' },
      'packages/client': { version: '0.16.3', dependencies: { 'p2p-hiverelay': '^0.16.3' } },
      'packages/verifier': { version: '0.16.3', dependencies: { 'p2p-hiverelay': '^0.16.3' } }
    }
  })
  await writeText(path.join(repo, 'README.md'), '# HiveRelay\n\n**Status: v0.16.3 packages; main has unreleased upgrades**\n')
  await writeJson(path.join(repo, 'fleet', 'channels.json'), { canary: 'v0.16.3', stable: 'v0.16.3' })
  await writeText(path.join(repo, 'umbrel-app', 'docker-compose.yml'), 'services:\n  app:\n    image: ghcr.io/bigdestiny2/p2p-hiverelay:0.16.3@sha256:' + 'b'.repeat(64) + '\n')
  await writeText(path.join(repo, 'umbrel-app', 'umbrel-app.yml'), 'version: "0.16.3"\nreleaseNotes: "old"\n')
  await writeText(path.join(repo, 'startos', 'manifest.yaml'), 'id: blindspark\nversion: 0.16.3\nrelease-notes: |\n  old\nlicense: apache-2.0\n')
  await writeText(path.join(repo, 'startos', 'Makefile'), 'VERSION ?= $(shell sed -n \'s/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' ../package.json | head -n 1)\n')
  await writeText(path.join(repo, 'startos', 'README.md'), 'Status: v0.16.3, one-page dashboard\n')
  await writeText(path.join(repo, 'truenas-app', 'app.yaml'), 'app_version: 0.16.3\nversion: 1.0.0\n')
  await writeText(path.join(repo, 'truenas-app', 'ix_values.yaml'), 'images:\n  image:\n    repository: ghcr.io/bigdestiny2/p2p-hiverelay\n    tag: 0.16.3\n')
  await writeText(path.join(repo, 'truenas-app', 'README.md'), '- Upstream HiveRelay release: `0.16.3`\n')
  await writeText(path.join(repo, 'unraid-app', 'templates', 'blindspark.xml'), '<Repository>ghcr.io/bigdestiny2/p2p-hiverelay:0.16.3</Repository>\n<Changes>HiveRelay 0.16.3</Changes>\n<Date>2026-01-01</Date>\n')
  await writeText(path.join(repo, 'zimaos-app', 'Apps', 'Blindspark', 'docker-compose.yml'), 'services:\n  blindspark:\n    image: ghcr.io/bigdestiny2/p2p-hiverelay:0.16.3\nx-casaos:\n  version: "0.16.3"\n  update_at: "2026-01-01"\n  release_notes:\n    en_US: HiveRelay 0.16.3 package\n')
  await writeJson(path.join(repo, 'runtipi-app', 'apps', 'blindspark', 'config.json'), { id: 'blindspark', version: '0.16.3', tipi_version: 1, updated_at: 1 })
  await writeText(path.join(repo, 'runtipi-app', 'apps', 'blindspark', 'docker-compose.yml'), 'services:\n  blindspark:\n    image: ghcr.io/bigdestiny2/p2p-hiverelay:0.16.3\n')
  await writeJson(path.join(repo, 'hexos-app', 'blindspark.json'), { version: 4, script: { version: '1.0.0', changeLog: 'Sync Blindspark for HiveRelay 0.16.3' } })
}

async function writeCommunityStoreFixture (store, oldVersion) {
  await writeJson(path.join(store, 'package.json'), { id: 'hiverelay-blindspark-store', version: oldVersion })
  await writeText(
    path.join(store, 'hiverelay-blindspark', 'docker-compose.yml'),
    'services:\n  app:\n    image: ghcr.io/bigdestiny2/p2p-hiverelay:' + oldVersion + '@sha256:' + 'c'.repeat(64) + '\n'
  )
  await writeText(
    path.join(store, 'hiverelay-blindspark', 'umbrel-app.yml'),
    'version: "' + oldVersion + '"\nreleaseNotes: >-\n  old\n'
  )
  await writeText(path.join(store, 'README.md'), 'Image: `ghcr.io/bigdestiny2/p2p-hiverelay:' + oldVersion + '`\n')
  await writeText(path.join(store, 'index.html'), '<p>ghcr.io/bigdestiny2/p2p-hiverelay:' + oldVersion + '</p>\n')
}

async function writeEcosystemConsumerFixture (root, oldVersion) {
  const sourceTermsByFile = new Map()
  for (const consumer of EXPECTED_CURRENT_CONSUMERS) {
    const packageFile = path.join(root, consumer.path)
    const packageDir = path.dirname(packageFile)
    const lockRoot = consumer.path.includes('/packages/opengit-relay/')
      ? path.join(root, '04-experiments', 'Opengit')
      : packageDir
    const packageEntryKey = path.relative(lockRoot, packageDir).split(path.sep).join('/')
    const oldDeps = Object.fromEntries(Object.keys(consumer.deps).map(dep => [dep, `^${oldVersion}`]))
    await writeJson(packageFile, {
      name: path.basename(packageDir),
      version: '0.0.0',
      dependencies: oldDeps
    })

    const packages = {
      [packageEntryKey === '' ? '' : packageEntryKey]: {
        dependencies: oldDeps
      }
    }
    for (const dep of Object.keys(consumer.deps)) {
      const target = path.resolve(packageDir, consumer.deps[dep].slice('file:'.length))
      const targetKey = path.relative(lockRoot, target).split(path.sep).join('/')
      packages[targetKey] = {
        name: dep,
        version: oldVersion
      }
      if (dep === 'p2p-hiverelay-client') {
        packages[targetKey].dependencies = {
          'p2p-hiverelay': `^${oldVersion}`
        }
      }
    }

    await writeJson(path.join(lockRoot, 'package-lock.json'), {
      name: path.basename(lockRoot),
      version: '0.0.0',
      lockfileVersion: 3,
      requires: true,
      packages
    })

    for (const spec of consumer.sourceChecks || []) {
      const term = typeof spec.termTemplate === 'string'
        ? spec.termTemplate.replaceAll('{version}', oldVersion)
        : spec.term
      if (typeof term === 'string') {
        const terms = sourceTermsByFile.get(spec.file) || []
        terms.push(term)
        sourceTermsByFile.set(spec.file, terms)
      }
    }
  }

  for (const [file, terms] of sourceTermsByFile) {
    await writeText(path.join(root, file), `${terms.join('\n')}\n`)
  }
}

async function writeJson (file, value) {
  await writeText(file, JSON.stringify(value, null, 2) + '\n')
}

async function writeText (file, text) {
  await mkdir(path.dirname(file), { recursive: true })
  await writeFile(file, text)
}