Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da84523a6d | |||
| ab4bc08e50 | |||
| 7862fab809 | |||
| 3436f3cbe2 | |||
| 0993a4d3bb | |||
| 270cbe628a | |||
| 764f890900 | |||
| da9df57e11 | |||
| 653d348ecc | |||
| 77bd43818f | |||
| 11c323b715 | |||
| 8fa0cfa271 | |||
| 75983ab3b1 | |||
| 4c0fe528dc | |||
| 7e703c93b1 | |||
| 1ba1c2a32a | |||
| ea08e76a2f | |||
| 6ecb2c366f | |||
| ac050c4459 | |||
| 30d93dddc5 | |||
| 5ed68c0065 | |||
| fcc181700e | |||
| a4ce4879a4 | |||
| 95aed2867d |
@@ -81,6 +81,13 @@ jobs:
|
|||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Verify the packed manifests
|
||||||
|
# `workspace:*` is mandatory in the manifests but meaningless to npm, so
|
||||||
|
# a range that survives into a tarball is an install failure for every
|
||||||
|
# consumer. The publish workflows run this too; running it here is what
|
||||||
|
# puts the failure on the pull request instead of on the release.
|
||||||
|
run: node scripts/verify-pack.mjs
|
||||||
|
|
||||||
- name: Upload coverage reports
|
- name: Upload coverage reports
|
||||||
if: always()
|
if: always()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Every commit that lands on `main` publishes a prerelease of both packages to
|
# Every commit that lands on `main` publishes a prerelease of every publishable
|
||||||
# the Gitea npm registry under the `main` dist-tag:
|
# package to the Gitea npm registry under the `main` dist-tag:
|
||||||
#
|
#
|
||||||
# pnpm add @bitstack/nopy@main
|
# pnpm add @bitsquare/nopy@main
|
||||||
#
|
#
|
||||||
# The verification gate runs here rather than in ci.yml so a snapshot can never
|
# The verification gate runs here rather than in ci.yml so a snapshot can never
|
||||||
# be published from a red `main`. Versions are derived, never committed —
|
# be published from a red `main`. Versions are derived, never committed —
|
||||||
@@ -35,6 +35,14 @@ jobs:
|
|||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Drop the repo's Gitea scope mapping
|
||||||
|
# See the same step in release.yml. This job only ever targets Gitea, so
|
||||||
|
# the committed file happens to agree with it — but it agrees by
|
||||||
|
# accident, and a project-level `@bitsquare:registry` silently outranks
|
||||||
|
# the userconfig written below. Removing it keeps the registry a
|
||||||
|
# property of the step rather than of the checkout.
|
||||||
|
run: rm -f .npmrc
|
||||||
|
|
||||||
- name: Set up pnpm
|
- name: Set up pnpm
|
||||||
# Version comes from `packageManager` in the root package.json.
|
# Version comes from `packageManager` in the root package.json.
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -78,6 +86,11 @@ jobs:
|
|||||||
# Explicit, so the publish step can skip lifecycle scripts entirely.
|
# Explicit, so the publish step can skip lifecycle scripts entirely.
|
||||||
run: pnpm run build
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Verify the packed manifests
|
||||||
|
# Packages link to each other with `workspace:*`, which npm cannot
|
||||||
|
# install. Proves on the tarball that pack rewrote it.
|
||||||
|
run: node scripts/verify-pack.mjs
|
||||||
|
|
||||||
- name: Authenticate against the Gitea registry
|
- name: Authenticate against the Gitea registry
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -87,7 +100,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
install -m 600 /dev/null "$NPMRC"
|
install -m 600 /dev/null "$NPMRC"
|
||||||
{
|
{
|
||||||
printf '@bitstack:registry=%s\n' "$REGISTRY"
|
printf '@bitsquare:registry=%s\n' "$REGISTRY"
|
||||||
printf '//%s:_authToken=%s\n' "${REGISTRY#*://}" "$REGISTRY_TOKEN"
|
printf '//%s:_authToken=%s\n' "${REGISTRY#*://}" "$REGISTRY_TOKEN"
|
||||||
} >> "$NPMRC"
|
} >> "$NPMRC"
|
||||||
|
|
||||||
@@ -97,23 +110,41 @@ jobs:
|
|||||||
export npm_config_userconfig="$NPMRC"
|
export npm_config_userconfig="$NPMRC"
|
||||||
: "${GITHUB_STEP_SUMMARY:=/dev/null}"
|
: "${GITHUB_STEP_SUMMARY:=/dev/null}"
|
||||||
short_sha=$(git rev-parse --short=7 HEAD)
|
short_sha=$(git rev-parse --short=7 HEAD)
|
||||||
|
# Dependencies first, so the registry never briefly holds a package
|
||||||
|
# whose dependency has not landed yet.
|
||||||
|
dirs=$(node scripts/publish-order.mjs)
|
||||||
|
|
||||||
for dir in packages/*/; do
|
# Pass 1: stamp every manifest before anything is packed. `pnpm
|
||||||
name=$(node -p "require('./${dir}package.json').name")
|
# publish` substitutes `workspace:*` with the version the linked
|
||||||
base=$(node -p "require('./${dir}package.json').version")
|
# package declares at pack time, so nopy-cubes has to be carrying its
|
||||||
|
# snapshot version by the time nopy is packed.
|
||||||
|
for dir in $dirs; do
|
||||||
|
base=$(node -p "require('./${dir}/package.json').version")
|
||||||
# `g` prefix keeps the identifier a valid semver one even when the
|
# `g` prefix keeps the identifier a valid semver one even when the
|
||||||
# abbreviated sha happens to be all digits.
|
# abbreviated sha happens to be all digits.
|
||||||
version="${base}-main.${{ github.run_number }}.g${short_sha}"
|
version="${base}-main.${{ github.run_number }}.g${short_sha}"
|
||||||
|
# `buildInfo.commit` is what `nopy --version` annotates itself with.
|
||||||
|
# An unknown top-level key is ignored by npm and package.json is
|
||||||
|
# always in the tarball, so it ships without any `files` change.
|
||||||
|
(cd "$dir" && npm pkg set "version=${version}" "buildInfo.commit=${short_sha}")
|
||||||
|
done
|
||||||
|
|
||||||
|
# Pass 2: publish.
|
||||||
|
for dir in $dirs; do
|
||||||
|
name=$(node -p "require('./${dir}/package.json').name")
|
||||||
|
version=$(node -p "require('./${dir}/package.json').version")
|
||||||
|
|
||||||
echo "::group::${name}@${version}"
|
echo "::group::${name}@${version}"
|
||||||
if npm view "${name}@${version}" version --registry "$REGISTRY" >/dev/null 2>&1; then
|
# Scoped, not `--registry`: for a scoped package npm resolves
|
||||||
|
# `@scope:registry` first, so a bare flag loses to any project
|
||||||
|
# .npmrc that sets the scoped key.
|
||||||
|
if npm view "${name}@${version}" version --@bitsquare:registry="$REGISTRY" >/dev/null 2>&1; then
|
||||||
echo "Already published — skipping (this is a re-run of the same workflow)."
|
echo "Already published — skipping (this is a re-run of the same workflow)."
|
||||||
else
|
else
|
||||||
(
|
# pnpm, not npm: npm ships `workspace:*` verbatim and the install
|
||||||
cd "$dir"
|
# then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because
|
||||||
npm pkg set "version=${version}"
|
# stamping the versions above left the tree dirty.
|
||||||
npm publish --ignore-scripts --tag main --registry "$REGISTRY"
|
(cd "$dir" && pnpm publish --ignore-scripts --no-git-checks --tag main --@bitsquare:registry="$REGISTRY")
|
||||||
)
|
|
||||||
fi
|
fi
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
# Tag-driven release of a single package.
|
# Tag-driven release of a single package.
|
||||||
#
|
#
|
||||||
# git tag nopy-v1.2.0 && git push origin nopy-v1.2.0
|
# git tag nopy-v1.2.0 && git push origin nopy-v1.2.0
|
||||||
|
# git tag nopy-cubes-v1.2.0 && git push origin nopy-cubes-v1.2.0
|
||||||
# git tag keyman-v1.2.0 && git push origin keyman-v1.2.0
|
# git tag keyman-v1.2.0 && git push origin keyman-v1.2.0
|
||||||
#
|
#
|
||||||
# The tag is the source of truth for *which* package ships; package.json is the
|
# The tag is the source of truth for *which* package ships; package.json is the
|
||||||
# source of truth for the version, and the two must agree or the run fails.
|
# source of truth for the version, and the two must agree or the run fails.
|
||||||
|
#
|
||||||
|
# Packages that link to each other release dependency-first — `nopy-cubes` before
|
||||||
|
# `nopy` — because the linked version is resolved at pack time. The run refuses
|
||||||
|
# to publish otherwise.
|
||||||
# A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead
|
# A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead
|
||||||
# of `latest`.
|
# of `latest`.
|
||||||
#
|
#
|
||||||
# Required secrets:
|
# Required secrets:
|
||||||
# NPM_TOKEN npmjs granular token, read-and-write on @bitstack/*, 2FA
|
# NPM_TOKEN npmjs granular token, read-and-write on @bitsquare/*, 2FA
|
||||||
# not required. Expires after 90 days — rotate it.
|
# not required. Expires after 90 days — rotate it.
|
||||||
# MYGITEA_NPM_TOKEN Gitea PAT with write:package. The automatic GITEA_TOKEN is
|
# MYGITEA_NPM_TOKEN Gitea PAT with write:package. The automatic GITEA_TOKEN is
|
||||||
# a repo-scoped task token and the package registry rejects it.
|
# a repo-scoped task token and the package registry rejects it.
|
||||||
@@ -40,6 +45,18 @@ jobs:
|
|||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Drop the repo's Gitea scope mapping
|
||||||
|
# The committed .npmrc points @bitsquare at Gitea so local work resolves
|
||||||
|
# snapshots. It must not survive into a publish job: it is a *project*
|
||||||
|
# config, which outranks both the userconfig the steps below write and a
|
||||||
|
# `--registry` flag, because `@scope:registry` is more specific than
|
||||||
|
# `registry`. Left in place, `pnpm publish --registry <npmjs>` uploads to
|
||||||
|
# Gitea and `npm view --registry <npmjs>` answers from Gitea — so the
|
||||||
|
# npmjs release silently publishes nowhere and then skips itself.
|
||||||
|
# Measured, not assumed. The checkout is disposable; each step below
|
||||||
|
# names its registry explicitly anyway.
|
||||||
|
run: rm -f .npmrc
|
||||||
|
|
||||||
- name: Resolve the release from the tag
|
- name: Resolve the release from the tag
|
||||||
id: target
|
id: target
|
||||||
run: |
|
run: |
|
||||||
@@ -108,6 +125,33 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check the linked workspace packages are already released
|
||||||
|
env:
|
||||||
|
NAME: ${{ steps.target.outputs.name }}
|
||||||
|
DIR: ${{ steps.target.outputs.dir }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# `pnpm publish` turns `workspace:*` into the version the linked
|
||||||
|
# package declares at this commit. If that version is not on the
|
||||||
|
# registry yet, the release installs to a broken tree — and npmjs
|
||||||
|
# only lets you unpublish for 72 hours. Release the dependency first:
|
||||||
|
# nopy-cubes, then nopy, then any bundle.
|
||||||
|
#
|
||||||
|
# npmjs only: it is the irreversible one, and it needs no credentials
|
||||||
|
# to read, which this step does not have yet.
|
||||||
|
missing=0
|
||||||
|
for spec in $(node scripts/linked-deps.mjs "$DIR" | tr ' ' '@'); do
|
||||||
|
# Scoped, not `--registry`: `@scope:registry` outranks it, so a bare
|
||||||
|
# flag can be silently overridden by any project-level .npmrc.
|
||||||
|
if npm view "$spec" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||||
|
echo "${spec} is published"
|
||||||
|
else
|
||||||
|
echo "::error::${NAME} depends on ${spec}, which is not on npmjs. Release it first."
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit "$missing"
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm run lint:ci
|
run: pnpm run lint:ci
|
||||||
|
|
||||||
@@ -121,6 +165,25 @@ jobs:
|
|||||||
# Explicit, so the publish steps can skip lifecycle scripts entirely.
|
# Explicit, so the publish steps can skip lifecycle scripts entirely.
|
||||||
run: pnpm run build
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Stamp the commit into the manifest
|
||||||
|
# What `nopy --version` annotates itself with. The version is untouched:
|
||||||
|
# this only adds a `buildInfo.commit` key, which npm ignores and which
|
||||||
|
# ships regardless of `files` because package.json is always packed.
|
||||||
|
# Before the pack below, so the artefact under test is the one publish
|
||||||
|
# ships. The tree is left dirty, which is why both publish steps pass
|
||||||
|
# --no-git-checks — they already did, for the detached HEAD.
|
||||||
|
env:
|
||||||
|
DIR: ${{ steps.target.outputs.dir }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
short_sha=$(git rev-parse --short=7 HEAD)
|
||||||
|
(cd "$DIR" && npm pkg set "buildInfo.commit=${short_sha}")
|
||||||
|
|
||||||
|
- name: Verify the packed manifests
|
||||||
|
# Packages link to each other with `workspace:*`, which npm cannot
|
||||||
|
# install. Proves on the tarball that pack rewrote it.
|
||||||
|
run: node scripts/verify-pack.mjs
|
||||||
|
|
||||||
- name: Publish to the Gitea registry
|
- name: Publish to the Gitea registry
|
||||||
env:
|
env:
|
||||||
NAME: ${{ steps.target.outputs.name }}
|
NAME: ${{ steps.target.outputs.name }}
|
||||||
@@ -131,15 +194,23 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
install -m 600 /dev/null "$NPMRC"
|
install -m 600 /dev/null "$NPMRC"
|
||||||
{
|
{
|
||||||
printf '@bitstack:registry=%s\n' "$GITEA_REGISTRY"
|
printf '@bitsquare:registry=%s\n' "$GITEA_REGISTRY"
|
||||||
printf '//%s:_authToken=%s\n' "${GITEA_REGISTRY#*://}" "$GITEA_REGISTRY_TOKEN"
|
printf '//%s:_authToken=%s\n' "${GITEA_REGISTRY#*://}" "$GITEA_REGISTRY_TOKEN"
|
||||||
} >> "$NPMRC"
|
} >> "$NPMRC"
|
||||||
export npm_config_userconfig="$NPMRC"
|
export npm_config_userconfig="$NPMRC"
|
||||||
|
|
||||||
if npm view "${NAME}@${VERSION}" version --registry "$GITEA_REGISTRY" >/dev/null 2>&1; then
|
if npm view "${NAME}@${VERSION}" version --@bitsquare:registry="$GITEA_REGISTRY" >/dev/null 2>&1; then
|
||||||
echo "${NAME}@${VERSION} is already on Gitea — skipping."
|
echo "${NAME}@${VERSION} is already on Gitea — skipping."
|
||||||
else
|
else
|
||||||
(cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --registry "$GITEA_REGISTRY")
|
# pnpm, not npm: npm ships `workspace:*` verbatim and the install
|
||||||
|
# then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because a
|
||||||
|
# tag build is a detached HEAD.
|
||||||
|
#
|
||||||
|
# The registry is named as `--@bitsquare:registry`, not `--registry`.
|
||||||
|
# Every package here is scoped, and for a scoped package npm resolves
|
||||||
|
# `@scope:registry` ahead of `registry` — so a bare flag loses to any
|
||||||
|
# project .npmrc that sets the scoped key.
|
||||||
|
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --@bitsquare:registry="$GITEA_REGISTRY")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish to npmjs
|
- name: Publish to npmjs
|
||||||
@@ -152,17 +223,22 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
install -m 600 /dev/null "$NPMRC"
|
install -m 600 /dev/null "$NPMRC"
|
||||||
{
|
{
|
||||||
printf '@bitstack:registry=%s\n' "$NPMJS_REGISTRY"
|
printf '@bitsquare:registry=%s\n' "$NPMJS_REGISTRY"
|
||||||
printf '//%s:_authToken=%s\n' "${NPMJS_REGISTRY#*://}" "$NPMJS_TOKEN"
|
printf '//%s:_authToken=%s\n' "${NPMJS_REGISTRY#*://}" "$NPMJS_TOKEN"
|
||||||
} >> "$NPMRC"
|
} >> "$NPMRC"
|
||||||
export npm_config_userconfig="$NPMRC"
|
export npm_config_userconfig="$NPMRC"
|
||||||
|
|
||||||
if npm view "${NAME}@${VERSION}" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
if npm view "${NAME}@${VERSION}" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||||
echo "${NAME}@${VERSION} is already on npmjs — skipping."
|
echo "${NAME}@${VERSION} is already on npmjs — skipping."
|
||||||
else
|
else
|
||||||
# No --provenance: that needs GitHub Actions OIDC, which Gitea has no
|
# No --provenance: that needs GitHub Actions OIDC, which Gitea has no
|
||||||
# equivalent for.
|
# equivalent for.
|
||||||
(cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --access public --registry "$NPMJS_REGISTRY")
|
#
|
||||||
|
# Scoped flag, as above — and it matters most here. With a bare
|
||||||
|
# `--registry` this line was measured uploading to Gitea whenever a
|
||||||
|
# project .npmrc mapped the scope, which is the one mistake npmjs
|
||||||
|
# will not let you take back.
|
||||||
|
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --access public --@bitsquare:registry="$NPMJS_REGISTRY")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Remove the registry credentials
|
- name: Remove the registry credentials
|
||||||
@@ -241,5 +317,8 @@ jobs:
|
|||||||
echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)"
|
echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`"
|
echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`"
|
||||||
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --registry ${GITEA_REGISTRY}\`"
|
# Scoped, never a bare `--registry`: Gitea serves @bitsquare only and
|
||||||
|
# does not proxy npmjs, so a bare flag sends every transitive
|
||||||
|
# dependency to a registry that has never heard of them.
|
||||||
|
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --@bitsquare:registry=${GITEA_REGISTRY}\`"
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
.vault
|
.vault
|
||||||
.vagrant
|
.vagrant
|
||||||
|
# Persistent SSH host key for the dev VM — a real private key, and machine-local
|
||||||
|
# anyway (see the Vagrantfile).
|
||||||
|
.vagrant-hostkeys
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
@@ -22,3 +25,8 @@ tsconfig.tsbuildinfo
|
|||||||
.npmrc
|
.npmrc
|
||||||
.npmrc-*
|
.npmrc-*
|
||||||
release.json
|
release.json
|
||||||
|
|
||||||
|
# ...except the repo-root .npmrc, which is checked in on purpose: it holds the
|
||||||
|
# @bitsquare -> Gitea scope mapping and nothing else. Credentials live in the
|
||||||
|
# .npmrc-* files above, which stay ignored.
|
||||||
|
!/.npmrc
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"hosts": [],
|
"hosts": [],
|
||||||
"cubeDirs": ["./cubes"],
|
"cubeDirs": [],
|
||||||
|
"cubePackages": ["@bitsquare/nopy-cubes-core"],
|
||||||
"env": {},
|
"env": {},
|
||||||
"log": {
|
"log": {
|
||||||
"verbosity": "info",
|
"verbosity": "info",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# The @bitsquare scope resolves from the Gitea registry rather than npmjs.
|
||||||
|
#
|
||||||
|
# Gitea is a strict superset: release.yml publishes there *and* to npmjs, while
|
||||||
|
# publish-snapshot.yml pushes a `main` snapshot on every push. So this is not a
|
||||||
|
# trade — it is every released version plus the ones npmjs has never seen, which
|
||||||
|
# is what makes a snapshot testable before it is released.
|
||||||
|
#
|
||||||
|
# Scoped deliberately. A bare `registry=` would send all ~55 transitive
|
||||||
|
# dependencies to Gitea too, and Gitea serves this scope only — it does not
|
||||||
|
# proxy npmjs, so they would all 404. Everything outside @bitsquare keeps going
|
||||||
|
# to the default registry.
|
||||||
|
#
|
||||||
|
# Reading is anonymous; no token belongs in this file. The publish workflows
|
||||||
|
# write their credentials to a throwaway .npmrc-gitea / .npmrc-release, both of
|
||||||
|
# which stay gitignored.
|
||||||
|
#
|
||||||
|
# Note this maps the *scope*, not a channel: Gitea currently publishes no
|
||||||
|
# `latest` dist-tag, so an untagged `npm i @bitsquare/nopy` resolves to nothing.
|
||||||
|
# Ask for a tag — @main for the newest snapshot. See README.PUBLISH.md.
|
||||||
|
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this repo is
|
||||||
|
|
||||||
|
A pnpm workspace holding two independently published CLIs, the authoring package
|
||||||
|
their deployment units are written against, and one bundle of those units:
|
||||||
|
|
||||||
|
| Path | Package | Binary | Role |
|
||||||
|
| -------------------------- | ----------------------------- | -------- | -------------------------------------------------------- |
|
||||||
|
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution |
|
||||||
|
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management, shelling out to `age` / `ssh-keygen` |
|
||||||
|
| `packages/nopy-cubes` | `@bitsquare/nopy-cubes` | — | the authoring surface a `manifest.mjs` imports |
|
||||||
|
| `packages/nopy-cubes-core` | `@bitsquare/nopy-cubes-core` | — | the core cube bundle (22 cubes), no TypeScript |
|
||||||
|
|
||||||
|
The root package is private; everything under `packages/` ships. `keyman` stands
|
||||||
|
alone, but `nopy` and `nopy-cubes-core` both depend on `nopy-cubes` (`workspace:*`), so
|
||||||
|
publish order matters — see *Releasing*.
|
||||||
|
|
||||||
|
`nopy-cubes-core` is consumed the way a third party would consume it: the root
|
||||||
|
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
|
||||||
|
`node_modules`. There is no `cubes/` directory at the repo root any more.
|
||||||
|
|
||||||
|
## Documenting
|
||||||
|
|
||||||
|
Be modest. Size the write-up to the change: most work needs none, and a small
|
||||||
|
module never earns a section in `docs/API.md`. Where a reason is genuinely
|
||||||
|
non-obvious, one comment next to the code beats three paragraphs in a document
|
||||||
|
nobody re-reads. Document the surprising, not the obvious.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install # also installs the git hooks via simple-git-hooks
|
||||||
|
pnpm run build # tsc --build across the TS packages (project references)
|
||||||
|
pnpm run typecheck # tsc --build (see below — it really does emit)
|
||||||
|
pnpm run lint # biome check . (lint:fix / lint:ci variants)
|
||||||
|
pnpm test # vitest run, every package with tests
|
||||||
|
pnpm run test:coverage # vitest with the coverage gate
|
||||||
|
pnpm run coverage:summary # renders the last coverage run as a Markdown table
|
||||||
|
pnpm run registry:status # what is on Gitea vs npmjs, and what is Gitea-only
|
||||||
|
pnpm run try:snapshot # install a published snapshot into a temp project and run it
|
||||||
|
```
|
||||||
|
|
||||||
|
Single package / single test:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter @bitsquare/nopy run test tests/config.test.ts # one file
|
||||||
|
pnpm --filter @bitsquare/nopy run test -t "merges configs" # by test name
|
||||||
|
pnpm --filter @bitsquare/nopy run test:watch
|
||||||
|
pnpm --filter @bitsquare/nopy run nopy # run the CLI from source via tsx
|
||||||
|
pnpm --filter @bitsquare/keyman run keyman
|
||||||
|
```
|
||||||
|
|
||||||
|
`typescript` is the 7.x native compiler, so `tsc` *is* the fast one — there is no
|
||||||
|
separate `tsgo` binary.
|
||||||
|
|
||||||
|
`typecheck` is plain `tsc --build`, not `--noEmit`. Once a project has
|
||||||
|
`references`, `--noEmit` is rejected outright (TS6310: *referenced project may
|
||||||
|
not disable emit*) — a composite project has to emit the declarations its
|
||||||
|
dependents read. So the typecheck writes `dist` as a side effect; it is
|
||||||
|
gitignored, and the upside is that the gate now also proves the build works.
|
||||||
|
|
||||||
|
## Verification gate
|
||||||
|
|
||||||
|
`lint:ci` → `typecheck` → `test:coverage` is one gate, run in three places: the
|
||||||
|
`pre-push` hook, `ci.yml` (non-`main`), and `publish-snapshot.yml` (`main`).
|
||||||
|
`pre-commit` runs Biome with fixes on staged files only. Bypass with
|
||||||
|
`SKIP_SIMPLE_GIT_HOOKS=1`; re-install after editing the hook config with
|
||||||
|
`pnpm exec simple-git-hooks`.
|
||||||
|
|
||||||
|
Coverage thresholds live in each package's `vitest.config.ts` (85 % branches and
|
||||||
|
functions, 80 % lines and statements), not in a CI flag — they fail identically
|
||||||
|
locally and on the runner. Barrel files (`src/index.ts`, `src/cubes/index.ts`,
|
||||||
|
`src/nopy.cubes.ts`) and the Commander argv wiring (`src/*.cli.ts`) are excluded;
|
||||||
|
adding logic to those files means moving it somewhere covered.
|
||||||
|
|
||||||
|
nopy's vitest config aliases `@bitsquare/nopy-cubes` to that package's **source**,
|
||||||
|
not to the workspace link (which points at a `dist` that only exists after a
|
||||||
|
build), so the gate does not depend on build ordering and can never run against a
|
||||||
|
stale artefact. The same config excludes `**/nopy-cubes/**` from coverage — without
|
||||||
|
it nopy's numbers absorb another package's files. `nopy-cubes-core` has no tests of its
|
||||||
|
own; the loader tests in nopy cover the contract it implements.
|
||||||
|
|
||||||
|
The three TS packages set `pool: 'forks'` because tests use `process.chdir()` — most
|
||||||
|
loader/config tests build a throwaway tree under `os.tmpdir()` and chdir into it,
|
||||||
|
since discovery is driven entirely by the working directory.
|
||||||
|
|
||||||
|
## nopy architecture
|
||||||
|
|
||||||
|
One pass per invocation, `nopy.main.ts` orchestrating:
|
||||||
|
|
||||||
|
1. **`nopy.config.ts`** — `loadConfig()` walks up from `process.cwd()` collecting
|
||||||
|
every `.nopyrc.json` plus `~/.nopyrc.json`, then merges them root-first.
|
||||||
|
Per-property strategy comes from the child's `resolution` block (`merge` is
|
||||||
|
the default: arrays concatenate and dedupe, objects deep-merge; `override`
|
||||||
|
replaces). Only properties listed in `PATH_PROPERTIES` (`cubeDirs`) get
|
||||||
|
relative paths resolved against their own config file's directory;
|
||||||
|
`cubePackages` needs the same origin for a different reason, so each entry is
|
||||||
|
normalised into a `CubePackageRef {spec, from}` — `from` is the directory of
|
||||||
|
the config that named it, which is where the package gets resolved from.
|
||||||
|
**Throws**
|
||||||
|
if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily
|
||||||
|
inside the action, so `--help`/`--version` work outside a project.
|
||||||
|
2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef`
|
||||||
|
into a package root plus its cube directories. The location is a **convention**:
|
||||||
|
`<root>/cubes`, so a bundle needs no nopy-specific `package.json` field at all.
|
||||||
|
`nopy.cubes` survives only as an override, for the bundle whose cubes are
|
||||||
|
elsewhere (`dist/cubes` after a build, say) — absent means the default, but
|
||||||
|
present-and-malformed is an error rather than a fall back, since saying
|
||||||
|
something that does not parse is not the same as saying nothing.
|
||||||
|
Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`,
|
||||||
|
deliberately bypassing the `exports` map: a bundle ships directories and has
|
||||||
|
no entry point to declare. `existsSync` also follows the symlink pnpm plants
|
||||||
|
at `node_modules/<name>`, which a `readdir` scan skips outright (it reports
|
||||||
|
`isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable
|
||||||
|
manifest, no cube directory found, and an entry pointing outside the package
|
||||||
|
root are all errors, never silent skips.
|
||||||
|
Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes
|
||||||
|
arrays of primitives and these are objects.
|
||||||
|
3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the
|
||||||
|
directories from `cubePackages`, and every ancestor directory holding a
|
||||||
|
`.npcubes` marker, then scans each recursively (skipping dotted dirs and
|
||||||
|
`node_modules`). A directory is a cube
|
||||||
|
when it holds both a manifest (`manifest.mjs` or `*.manifest.mjs`) and a
|
||||||
|
deploy script (`deploy.py` or `*.deploy.py`); manifests are loaded by dynamic
|
||||||
|
`import()`. Cube id = `manifest.id` → a `[id]` prefix in `manifest.name` →
|
||||||
|
the directory basename. Ids are flat and need not mirror the path
|
||||||
|
(`cubes/network/tailscale` declares `net:tailscale`), and they are claimed
|
||||||
|
**globally**, not per source: a duplicate is a hard error naming every
|
||||||
|
claimant, with no precedence rule and no shadowing. Each cube carries a
|
||||||
|
`source` — `{type: 'dir', dir}` or `{type: 'package', packageName, dir}` —
|
||||||
|
which is what makes that error legible when the collision is between a local
|
||||||
|
tree and an installed bundle. Duplicate ids and bad manifests become entries
|
||||||
|
in `errors`, which aborts the run.
|
||||||
|
4. **`nopy.workflow.ts`** — picks interactive, file-replay, or history-replay and
|
||||||
|
normalises all three into a `WorkflowResult`. Replays never re-prompt except
|
||||||
|
for passwords (never persisted) and a missing host.
|
||||||
|
5. **`cubes/dependencies.ts` → `BuildContext.resolveCube()`** — the core.
|
||||||
|
Recursive, per (cube, host): assign params and schema defaults → collect
|
||||||
|
variables (prompt, or read them back from the session on replay) → run
|
||||||
|
`before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives
|
||||||
|
the *collected* variables) → emit the deploy call → run `after` hooks. There
|
||||||
|
is no separate topological sort; ordering falls out of the recursion, and a
|
||||||
|
`${cubeId}:${host}` set makes emission idempotent. Hooks get a `HookContext`
|
||||||
|
whose `exec(id, vars)` re-enters `resolveCube`, so a hook can pull in a cube
|
||||||
|
that is not a declared dependency.
|
||||||
|
6. **`nopy.executor.ts`** — runs the built `pyinfra <host> -y --data K=V ... --chdir <cubeDir> <script>`
|
||||||
|
commands through execa with inherited stdio, sequentially, stopping at the
|
||||||
|
first failure unless `continueOnError`.
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
`Variables` (`nopy.common.ts`) holds one `Variable` per (cube, key). A `Variable`
|
||||||
|
is a list of `Assignment {value, origin}`, and precedence is the `Origin` rank:
|
||||||
|
`default(0) < env(1) < session(2) < prompt(3) < param(4)`. There are no scope
|
||||||
|
bags — config `env` is seeded per cube as a real assignment, so the old
|
||||||
|
`get('global')` (a cube id that was never a cube) is gone, and a replay assigns at
|
||||||
|
`session` instead of being smuggled into the prompts bag.
|
||||||
|
|
||||||
|
`assignments` is the true history, newest first, never reordered. `ordered` is a
|
||||||
|
**stable** sort of it by rank; `value` / `origin` read its head. The stability is
|
||||||
|
load-bearing: it is what makes same-origin ties resolve to the newest while the
|
||||||
|
displaced value stays visible in the trace. The trace is never persisted.
|
||||||
|
|
||||||
|
`get(id)` returns the effective values (→ the pyinfra command line);
|
||||||
|
`persistable(id)` returns the same minus declared secrets (→ session and history).
|
||||||
|
Every schema key is guaranteed present on the pyinfra side; pyinfra parses
|
||||||
|
`--data` values itself, so `"true"` arrives as a bool and numeric strings as ints.
|
||||||
|
|
||||||
|
A manifest's `secrets: string[]` names schema keys holding sensitive values —
|
||||||
|
validated at load (an entry that is not a schema key aborts the run). Secrets are
|
||||||
|
excluded from `persistable()`, re-prompted on replay via `fillSessionGaps`
|
||||||
|
(`requiredKeys() ∪ secrets`), and masked by `maskCommand()` / `maskVariables()`
|
||||||
|
wherever a command is printed. Deliberately a plain array rather than zod
|
||||||
|
metadata: `.meta()` and `.describe()` live in the per-copy `z.globalRegistry`, so
|
||||||
|
a manifest built by a different zod copy would look up empty — fail-open is
|
||||||
|
tolerable for a prompt label and not for a secret marker. See `docs/REFACTORING.md`
|
||||||
|
items 6 and 7.
|
||||||
|
|
||||||
|
### Cube contract
|
||||||
|
|
||||||
|
A cube directory holds `manifest.mjs` + `deploy.py`; anything else in it is
|
||||||
|
ignored by the loader but reachable from the script, which runs with the cube
|
||||||
|
directory as its cwd. Manifests are ESM, import `Manifest` from
|
||||||
|
`@bitsquare/nopy-cubes`, and declare `id`, `name`, a Zod `schema` (each field
|
||||||
|
`.describe()`d — the description is the prompt label — and `.default()`ed), plus
|
||||||
|
optional `secrets`/`dependencies`/`before`/`after`.
|
||||||
|
|
||||||
|
Import from **`@bitsquare/nopy-cubes`**, not `@bitsquare/nopy`. The authoring
|
||||||
|
surface is types and a factory, with zod as its only peer — no CLI, no prompts,
|
||||||
|
no process spawning — so a bundle can depend on it without dragging the CLI in.
|
||||||
|
`@bitsquare/nopy` re-exports all of it (`cubes.Manifest`, `cubes.uniqid`, …), so
|
||||||
|
the older form still works; every cube in `packages/nopy-cubes-core` has been moved to
|
||||||
|
the new one.
|
||||||
|
|
||||||
|
Manifests are resolved by ordinary Node resolution **from the manifest's own
|
||||||
|
directory**, which used to mean a hand-written local cube failed with
|
||||||
|
`ERR_MODULE_NOT_FOUND` unless you linked the package. `cubes/resolve-hook.mjs`
|
||||||
|
retires that: `loadCubes()` registers a `module.register()` resolve hook that
|
||||||
|
tries normal resolution *first* and only on failure falls back to resolving
|
||||||
|
`@bitsquare/nopy-cubes`, `@bitsquare/nopy` and `zod` from the running CLI's own
|
||||||
|
`node_modules`. Ordinary-resolution-first is the load-bearing part — a cube that
|
||||||
|
ships its own zod keeps it. The hook is a convenience, never load-bearing:
|
||||||
|
registration is wrapped in a `try`, and a bundle installed properly never reaches
|
||||||
|
it. `dist/cubes/*.mjs` is copied by the build, not compiled — hence
|
||||||
|
`"build": "tsc && cp src/cubes/*.mjs dist/cubes/"`.
|
||||||
|
|
||||||
|
Its tests must spawn a real `node` child process. Written inside the vitest
|
||||||
|
worker they prove nothing: vite resolves the dynamic import itself, so they pass
|
||||||
|
whether or not the hook is installed — verified by commenting the registration
|
||||||
|
out and watching them stay green.
|
||||||
|
|
||||||
|
## keyman architecture
|
||||||
|
|
||||||
|
Much smaller: `keyman.cli.ts` (wiring only — argv parsing lives in
|
||||||
|
`keyman.args.ts`, which is covered, and the CLI is the error boundary that turns a
|
||||||
|
`UsageError` into one line instead of a stack trace) → `keyman.main.ts`, an
|
||||||
|
inquirer menu loop dispatching to one module per operation
|
||||||
|
(`list`/`copy`/`generate`/`encrypt`/`decrypt`/`rotate`/`retire`/`clear`).
|
||||||
|
`keyman.config.ts` mirrors nopy's upward traversal for `.keymanrc.json` but not
|
||||||
|
its `resolution` merge: every keyman property is a string, so a child simply wins
|
||||||
|
and the strategies could not change an outcome — see `docs/AUDIT.md` §3.3. It
|
||||||
|
validates with Zod, falls back to defaults instead of throwing, and warns about a
|
||||||
|
key it does not know rather than letting Zod strip it silently. `VAULT_ROOT` in
|
||||||
|
the environment beats the config file. It shells out to `age`, `age-keygen`
|
||||||
|
(`-y`, to derive the recipient from the identity rather than trusting the
|
||||||
|
`# public key:` comment) and `ssh-keygen` (`-y`, to recover a missing `.pub`),
|
||||||
|
which must be on `PATH`; `runTool` tells a missing binary apart from a refusing
|
||||||
|
one. Nothing shells out to `cp` or `chmod` any more — `decrypt` copies and
|
||||||
|
chmods in-process, because the old spawn left a private key at age's 0644 for the
|
||||||
|
length of two processes.
|
||||||
|
|
||||||
|
The write path is one function, `storeInVault` (`keyman.vault.ts`), shared by
|
||||||
|
`encrypt`, `generate` and `rotate`; `listVaultKeys` is the one reader of the
|
||||||
|
`<keysDir>/<name>/id_<name>.age` layout. Rotation is deliberately two operations
|
||||||
|
(`rotate` adds a replacement under the next name in the series, `retire` deletes
|
||||||
|
the superseded key), because a rotation that replaces the key in place locks you
|
||||||
|
out of the host it was for. keyman never handles a passphrase: `ssh-keygen`
|
||||||
|
prompts for it with stdio inherited, since `-N <value>` put it in argv where `ps`
|
||||||
|
could read it.
|
||||||
|
|
||||||
|
`docs/AUDIT.md` is a full audit of the package with each finding marked closed as
|
||||||
|
it landed, and `docs/PLAN.md` the ten phases that closed them. Both are records
|
||||||
|
now, not plans.
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
|
`nopy.update.ts` and `keyman.update.ts` are two near-identical copies of one
|
||||||
|
module: derive the channel from the running version (`-main.` → `main`, any
|
||||||
|
other prerelease → `next`, clean → `latest`), resolve the registry from
|
||||||
|
`npm config get @bitsquare:registry`, read `dist-tags` off the packument with a
|
||||||
|
plain `fetch`, compare with semver. Nothing about the channel is stored — the
|
||||||
|
version you are running is the one piece of state that is always right, so an
|
||||||
|
upgrade cannot silently move you to a different channel.
|
||||||
|
|
||||||
|
They back a `self-update` subcommand and a once-a-day startup check whose hint
|
||||||
|
goes to **stderr**, so `--json` and `--print-only` stay machine-readable. The
|
||||||
|
cache is `~/.nopy/update-check.json` / `~/.keyman/update-check.json`; a
|
||||||
|
mismatched channel or registry in the cache is never treated as fresh. The
|
||||||
|
check is disabled whenever `CI` is set.
|
||||||
|
|
||||||
|
The install command uses `--@bitsquare:registry=<url>`, never `--registry`:
|
||||||
|
Gitea serves the `@bitsquare` scope and does **not** proxy npmjs, so a global
|
||||||
|
`--registry` would send every transitive dependency to a registry that has never
|
||||||
|
heard of them. Verified — `npm i -g @bitsquare/nopy@main --@bitsquare:registry=…`
|
||||||
|
pulls `nopy-cubes` from Gitea and the other 55 packages from npmjs. pnpm accepts
|
||||||
|
the same flag; the `npm_config_@bitsquare:registry` env var does not work with
|
||||||
|
pnpm and is not used.
|
||||||
|
|
||||||
|
The duplication between the two modules is deliberate: keyman shares no internal
|
||||||
|
library with nopy, and a fifth workspace package for ~250 lines would add
|
||||||
|
another edge to the publish order. Extract it if a third CLI appears.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Tag-driven, one package at a time; see `README.PUBLISH.md`.
|
||||||
|
|
||||||
|
### Registry resolution
|
||||||
|
|
||||||
|
The repo commits a root `.npmrc` mapping `@bitsquare:registry` to the Gitea
|
||||||
|
registry, so every npm/pnpm command run from the repo — global installs
|
||||||
|
included, since npm reads the project file for those too — resolves the scope
|
||||||
|
from Gitea. `.gitignore` ignores `.npmrc` generally (the workflows write
|
||||||
|
credentials to `.npmrc-gitea` / `.npmrc-release`) and carries a `!/.npmrc`
|
||||||
|
negation for the root file, which holds the mapping and no token.
|
||||||
|
|
||||||
|
Not a trade against npmjs: Gitea is a strict superset for this scope, since
|
||||||
|
`release.yml` publishes to both and `publish-snapshot.yml` adds a `main`
|
||||||
|
snapshot per push. It cannot affect `pnpm install` either — every `@bitsquare`
|
||||||
|
range in the workspace is `workspace:*` resolving to `link:`, so nothing in the
|
||||||
|
tree is fetched from that scope.
|
||||||
|
|
||||||
|
Two consequences: a bare `npm view @bitsquare/…` from the repo now answers for
|
||||||
|
**Gitea**, and an *untagged* install resolves to nothing, because Gitea
|
||||||
|
publishes no `latest` tag yet — always name `@main` or `@next`.
|
||||||
|
`pnpm run registry:status` prints both registries side by side and marks the
|
||||||
|
versions Gitea has that npmjs does not.
|
||||||
|
|
||||||
|
The sharp edge is in CI. `@scope:registry` is resolved *before* `registry` for a
|
||||||
|
scoped package, so the scoped key beats a `--registry` flag; and a project
|
||||||
|
`.npmrc` outranks the userconfig the workflows write. With the committed file in
|
||||||
|
place, `pnpm publish --registry <npmjs>` was measured uploading to **Gitea**, and
|
||||||
|
the `npm view --registry <npmjs>` guard answered from Gitea and skipped the npmjs
|
||||||
|
publish. Both workflows now `rm -f .npmrc` after checkout *and* pass
|
||||||
|
`--@bitsquare:registry=<url>` on every publish and lookup; either alone is
|
||||||
|
sufficient, and both were verified with `pnpm publish --dry-run`. This is the
|
||||||
|
same reason `self-update` never emits a bare `--registry`.
|
||||||
|
|
||||||
|
**Versions are `0.x.y`, not `1.0.0-alphaN`.** The dist-tag rule in `release.yml`
|
||||||
|
is mechanical — anything with a `-` goes out as `next` — so while every package
|
||||||
|
carried an `alphaN` suffix, `latest` never moved. `latest` on npmjs pointed at
|
||||||
|
`1.0.0-alpha5` only because npmjs sets it on a package's *first* publish
|
||||||
|
regardless of `--tag`; on Gitea it did not exist at all. Note that `npm view
|
||||||
|
<name>` against a registry with no `latest` tag prints nothing and exits **0**,
|
||||||
|
which is why this looked like a working lookup. (`npm view <name>@<version>`
|
||||||
|
does exit 1 for a missing version, so the workflows' idempotency guards are
|
||||||
|
fine.) All four packages were reset to `0.5.0`; `1.0.0-alpha5` stays the
|
||||||
|
numerically highest version on npmjs, so install with an explicit `@latest`.
|
||||||
|
|
||||||
|
- Push to `main` → `publish-snapshot.yml` publishes every package to the Gitea
|
||||||
|
registry as `<version>-main.<run>.g<sha>` under the `main` dist-tag. The
|
||||||
|
version is set on the runner with `npm pkg set` and never committed.
|
||||||
|
- `git tag <dir>-v<version>` (e.g. `nopy-v1.2.0` — the directory under
|
||||||
|
`packages/`, not the npm name) → `release.yml` publishes to Gitea *and* npmjs.
|
||||||
|
The tag chooses the package, `package.json` supplies the version, and the run
|
||||||
|
fails if they disagree. A prerelease version goes out as `next`, otherwise
|
||||||
|
`latest`.
|
||||||
|
|
||||||
|
So: bump `packages/<pkg>/package.json`, land it on `main`, then tag that commit.
|
||||||
|
|
||||||
|
Both workflows also stamp `buildInfo.commit` (the 7-char sha) into the manifest
|
||||||
|
with the same `npm pkg set`, never committed either — the snapshot loop stamps
|
||||||
|
every package, and the release step stamps whichever one the tag named. Both
|
||||||
|
CLIs append it to `--version` in parentheses — `0.5.0 (ab12cd7)` — and print the
|
||||||
|
bare version when the field is absent, which is every run from source. The
|
||||||
|
version string itself is untouched: `nopy.cli.ts` and `keyman.cli.ts` decorate
|
||||||
|
only the string they print, while `updateNotice()` and `selfUpdate()` keep
|
||||||
|
reading the raw `version`, so channel derivation never sees the annotation. An
|
||||||
|
unknown top-level key is ignored by npm and `package.json` is always packed, so
|
||||||
|
nothing in `files` had to change. The two CLIs are kept in step here for the
|
||||||
|
same reason their update modules are duplicated rather than shared.
|
||||||
|
|
||||||
|
Three things the `workspace:*` links added, all of them non-obvious:
|
||||||
|
|
||||||
|
- **`pnpm publish`, never `npm publish`.** `link-workspace-packages` is unset and
|
||||||
|
pnpm 10+ defaults it to `false`, so `workspace:*` is mandatory in the manifests
|
||||||
|
— and npm does not understand it. `npm pack` ships the literal string and the
|
||||||
|
install fails with `EUNSUPPORTEDPROTOCOL`; `pnpm pack`/`pnpm publish` substitute
|
||||||
|
the real version at pack time. Both directions were measured, not assumed.
|
||||||
|
- **`scripts/verify-pack.mjs`** packs every non-private package and fails if any
|
||||||
|
`workspace:` range survived into a tarball. It runs in both publish workflows.
|
||||||
|
Note `pnpm pack` has no `--ignore-scripts` flag, so `prepack` does rebuild —
|
||||||
|
which means the artefact under test is the one publish ships.
|
||||||
|
- **Order.** `packages/*/` sorts `nopy` before `nopy-cubes`, which is backwards.
|
||||||
|
`scripts/publish-order.mjs` topologically sorts over the `workspace:` edges;
|
||||||
|
the snapshot workflow stamps *every* version first and only then publishes in
|
||||||
|
that order, because `pnpm publish` reads the linked package's version at pack
|
||||||
|
time. `release.yml` additionally refuses to ship a package whose linked
|
||||||
|
dependency is not yet on npmjs (`scripts/linked-deps.mjs`) — npmjs is the
|
||||||
|
registry you cannot take a mistake back from.
|
||||||
|
|
||||||
|
## Known drift
|
||||||
|
|
||||||
|
`logConfigToFlags()` is exported and tested but nothing feeds its output into the
|
||||||
|
built pyinfra command, so `log.verbosity` / `log.debug` in `.nopyrc.json`
|
||||||
|
currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record.
|
||||||
|
|
||||||
|
The publish lane has now run against the Gitea registry: all four packages are
|
||||||
|
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
|
||||||
|
project with npm and runs the binary. The npmjs lane has only ever published
|
||||||
|
`@bitsquare/nopy`; `keyman`, `nopy-cubes` and `nopy-cubes-core` have never been
|
||||||
|
released there, so the *check linked deps are released* guard in `release.yml`
|
||||||
|
will stop the first `nopy` release until `nopy-cubes` ships.
|
||||||
|
|
||||||
|
Nothing checks that a bundle and the CLI reading it are compatible versions;
|
||||||
|
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
|
||||||
|
of this came from and is now a record of what was built, including what differed
|
||||||
|
from the plan.
|
||||||
|
|
||||||
|
`docs/API.md` was regenerated against the source and now covers every export in
|
||||||
|
`src/index.ts` plus the authoring package; its *Known gaps* section is the short
|
||||||
|
list of behaviour that surprises a reader (`--json` printing nothing on success,
|
||||||
|
`DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated,
|
||||||
|
no cycle detection, and `self-update` reporting an empty dist-tag as an
|
||||||
|
unreachable registry). `CubePackageRef` is referenced by the exported
|
||||||
|
`NopyConfig` but is not itself re-exported, so a consumer cannot name the type —
|
||||||
|
one line, not yet fixed. `DOCS-AUDIT.md` tracks the drift in the remaining
|
||||||
|
documents; §2.9 (the nopy README shipping yarn-workspace instructions to npmjs)
|
||||||
|
and §2.10 (the keyman README describing four of nine operations and inventing a
|
||||||
|
tenth) are both closed. The keyman README now quotes `helpText()` verbatim and a
|
||||||
|
test fails if the two diverge, which is the shape worth copying for nopy.
|
||||||
+847
@@ -0,0 +1,847 @@
|
|||||||
|
# Documentation audit
|
||||||
|
|
||||||
|
Every claim in the repository's Markdown was checked against the source it
|
||||||
|
describes. Findings are grouped by *kind of divergence*, because the fix differs:
|
||||||
|
a phantom feature needs a decision (build it or delete the docs), a wrong claim
|
||||||
|
needs an edit, a gap needs prose.
|
||||||
|
|
||||||
|
Severity is about what it costs a reader:
|
||||||
|
|
||||||
|
- **🔴 broken** — following the documentation produces a wrong result or a crash.
|
||||||
|
- **🟠 misleading** — the documentation states something the code does not do.
|
||||||
|
- **🟡 gap** — the code does something real that no document mentions.
|
||||||
|
|
||||||
|
Verified against the working tree at commit `fcc1817`. Line numbers are from that
|
||||||
|
state.
|
||||||
|
|
||||||
|
Findings closed since are marked **✅ … fixed** and keep their original text as
|
||||||
|
the record of what was wrong. So far: §1.1 (`--use-defaults`), §2.2
|
||||||
|
(`getDefaults()`), §2.1 (precedence — the second half closed differently than
|
||||||
|
proposed), §3 in full (`docs/API.md`, regenerated), §4.2 (password on stdout —
|
||||||
|
points 1 and 2 of 3), §4.3 (what a session records), §2.9 (the nopy README's
|
||||||
|
yarn install instructions), and one bullet of §6.4.
|
||||||
|
|
||||||
|
Closing §3 also settled the documentation half of several findings elsewhere
|
||||||
|
without touching their underlying cause: §1.2, §1.3, §1.5, §2.3, §2.7, §4.4 and
|
||||||
|
§6.5 are each now stated accurately in `docs/API.md`, but the code still behaves
|
||||||
|
as those findings describe and they stay open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [1. Documented features that do not exist](#1-documented-features-that-do-not-exist)
|
||||||
|
- [2. Documented behaviour that differs from the code](#2-documented-behaviour-that-differs-from-the-code)
|
||||||
|
- [3. ✅ `docs/API.md` — systematic drift — fixed](#3--docsapimd--systematic-drift--fixed)
|
||||||
|
- [4. Undocumented behaviour](#4-undocumented-behaviour)
|
||||||
|
- [5. Cube documentation](#5-cube-documentation)
|
||||||
|
- [6. Defects found while verifying](#6-defects-found-while-verifying)
|
||||||
|
- [7. Checked and accurate](#7-checked-and-accurate)
|
||||||
|
- [Suggested order of attack](#suggested-order-of-attack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Documented features that do not exist
|
||||||
|
|
||||||
|
These are the same class of problem as the `--parallel` flag that was removed
|
||||||
|
earlier: documented in detail, absent from the source.
|
||||||
|
|
||||||
|
### 1.1 ✅ `-D, --use-defaults` does nothing — **fixed**
|
||||||
|
|
||||||
|
> **Resolved.** The flag is now implemented; see `docs/REFACTORING.md` item 5.
|
||||||
|
> `BuildContext.resolveCube` skips the prompts, `env` in `.nopyrc.json` outranks
|
||||||
|
> the schema default so a non-interactive run can be configured, and a cube with
|
||||||
|
> a variable nothing can fill aborts the run by name instead of deploying it
|
||||||
|
> blank. The finding below is kept as the record of what was wrong.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Docs say** | `README.md:291` "Install with defaults (no prompts for customization)"; `docs/API.md:39` "Skip variable prompts, use defaults"; `nopy.cli.ts:54` "Run cubes with default values without prompts" |
|
||||||
|
| **Code does** | Nothing. |
|
||||||
|
|
||||||
|
The option is threaded through four layers and then dropped. `nopy.cli.ts:95` →
|
||||||
|
`nopy.main.ts:125` → `nopy.main.ts:174` → `BuildContext.options.useDefaults`
|
||||||
|
(`cubes/dependencies.ts:35`), where it is **never read**. The only branch that
|
||||||
|
skips prompting is `isSessionReplay` (`cubes/dependencies.ts:62`).
|
||||||
|
`runInteractiveWorkflow` destructures only `useAuthKey` and ignores its
|
||||||
|
`useDefaults` too (`nopy.workflow.ts:50`).
|
||||||
|
|
||||||
|
Every documented `-D` invocation — including
|
||||||
|
`nopy install -D --save-session automated-deployment.nopysession.json`
|
||||||
|
(`README.md:226`), which is presented as the way to do an unattended run —
|
||||||
|
prompts for every variable of every cube.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ grep -rn "useDefaults" packages/nopy/src/
|
||||||
|
nopy.workflow.ts:19 useDefaults?: boolean; # declared
|
||||||
|
nopy.main.ts:94 useDefaults?: boolean; # declared
|
||||||
|
nopy.main.ts:125 useDefaults = false, # defaulted
|
||||||
|
nopy.main.ts:158 { useDefaults, useAuthKey },# passed
|
||||||
|
nopy.main.ts:174 useDefaults, # passed
|
||||||
|
nopy.cli.ts:95 useDefaults: options... # passed
|
||||||
|
cubes/dependencies.ts:35 useDefaults?: boolean; # declared — and that is all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 🔴 `-j, --json` produces no output on success
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Docs say** | `README.md:18` "**JSON output** for CI/CD integration"; `README.md:360-367` "Machine-readable JSON output for scripting and CI/CD integration"; `docs/API.md:573` |
|
||||||
|
| **Code does** | Emits JSON **only** on failure. |
|
||||||
|
|
||||||
|
`jsonOutput` reaches three places in `nopy.main.ts` (140, 150, 219) and none of
|
||||||
|
them prints a result. It suppresses the config banner, prints
|
||||||
|
`{success: false, errors}` when cube *loading* fails, and suppresses progress
|
||||||
|
lines. The success path at `nopy.main.ts:226-236` returns the `NopyResult` object
|
||||||
|
to the caller, and `nopy.cli.ts:107-110` inspects `result.success` without
|
||||||
|
printing it.
|
||||||
|
|
||||||
|
A CI job running `nopy install --json` gets pyinfra's inherited stdio and nothing
|
||||||
|
machine-readable. The exit code is the only usable signal.
|
||||||
|
|
||||||
|
Related: `--dry-run --json` prints the **text** plan, not JSON.
|
||||||
|
`executeDeployCalls` calls `outputExecutionPlan(calls)` without the `asJson`
|
||||||
|
argument (`nopy.executor.ts:172`), even though the function supports it
|
||||||
|
(`nopy.executor.ts:110`).
|
||||||
|
|
||||||
|
### 1.3 🟠 `log.verbosity` and `log.debug` have no effect
|
||||||
|
|
||||||
|
Pre-existing known drift, recorded in `CLAUDE.md`, but the README still presents
|
||||||
|
it as a working feature — two tables, a recommendation paragraph, and a slot in
|
||||||
|
the config example (`README.md:127-130`, `143-163`).
|
||||||
|
|
||||||
|
`logConfigToFlags()` (`nopy.config.ts:352`) is exported and has 8 unit tests, but
|
||||||
|
nothing calls it. `buildDeployCall` (`cubes/dependencies.ts:105-124`) constructs
|
||||||
|
the pyinfra argv without consulting `config.log` at all.
|
||||||
|
|
||||||
|
This is live in the repo's own config: `packages/nopy/.nopyrc.json` sets
|
||||||
|
`"verbosity": "trace", "debug": true` and gets neither.
|
||||||
|
|
||||||
|
### 1.4 🟠 Manifest `env` property
|
||||||
|
|
||||||
|
`README.md:14` lists "**Default values** with optional customization via manifest
|
||||||
|
`env`". `env` was removed from `Manifest` — see `docs/REFACTORING.md` item 3, and
|
||||||
|
the current interface at `cubes/types.ts:43-56`, which has `id`, `name`,
|
||||||
|
`schema`, `dependencies`, `before`, `after` and nothing else.
|
||||||
|
|
||||||
|
### 1.5 🟠 Topological sorting
|
||||||
|
|
||||||
|
`README.md:11` ("Dependency resolution with **topological sorting**"),
|
||||||
|
`README.md:25` ("Topologically sorts cubes based on dependencies") and
|
||||||
|
`README.md:381` ("because cubes are topologically sorted") describe an algorithm
|
||||||
|
that does not exist.
|
||||||
|
|
||||||
|
There is no sort. `BuildContext.resolveCube` recurses depth-first and pushes each
|
||||||
|
cube after its dependencies, with a `${cubeId}:${host}` set for idempotence
|
||||||
|
(`cubes/dependencies.ts:43-100`, `105-143`). The ordering is a side effect of the
|
||||||
|
recursion order.
|
||||||
|
|
||||||
|
This matters beyond vocabulary: a topological sort detects cycles, and this does
|
||||||
|
not. Two cubes that depend on each other recurse until the stack overflows —
|
||||||
|
`resolvedCubes` is only consulted in `buildDeployCall`, which runs *after* the
|
||||||
|
recursive call. `docs/API.md:160` still promises `Error` "if ... circular
|
||||||
|
dependency detected".
|
||||||
|
|
||||||
|
> The `API.md` promise is gone (§3): the regenerated file states that ordering
|
||||||
|
> falls out of the recursion and that there is no cycle detection. The README
|
||||||
|
> claims and the missing detection itself both stand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Documented behaviour that differs from the code
|
||||||
|
|
||||||
|
### 2.1 ✅ Variable precedence is wrong in both directions — **fixed**
|
||||||
|
|
||||||
|
> **Resolved, though the second pair closed the opposite way to the README's
|
||||||
|
> original claim.** `env` now outranks the Zod defaults, as documented — that was
|
||||||
|
> a prerequisite for `--use-defaults` being configurable at all.
|
||||||
|
>
|
||||||
|
> Dependency/hook params still outrank prompts, deliberately. They do not compete
|
||||||
|
> in practice: `VariableAssignment` leaves out any key a dependency supplied, so
|
||||||
|
> the operator is never asked about it and there is no typed value to override.
|
||||||
|
> The README documents the real order rather than the old promise.
|
||||||
|
>
|
||||||
|
> The underlying complaint — that precedence was the field order of an object
|
||||||
|
> literal and so could be neither named nor questioned — is what
|
||||||
|
> `docs/REFACTORING.md` item 6 addresses. `Origin` now ranks
|
||||||
|
> `default < env < session < prompt < param` as data, and every value carries the
|
||||||
|
> origin it came from.
|
||||||
|
|
||||||
|
`README.md:107-113` stated:
|
||||||
|
|
||||||
|
> **Priority order (lowest to highest):**
|
||||||
|
> 1. Zod schema `.default()` values
|
||||||
|
> 2. Global `env` from `.nopyrc.json`
|
||||||
|
> 3. Accumulated variables from dependencies
|
||||||
|
> 4. User prompts / session replay
|
||||||
|
|
||||||
|
`Variables.get()` (`nopy.common.ts:33-39`) does:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return {
|
||||||
|
...this.global, // 1. config env (lowest)
|
||||||
|
...this.defaults[id], // 2. Zod defaults
|
||||||
|
...this.prompts[id], // 3. what the user typed
|
||||||
|
...this.params[id], // 4. dependency / hook (highest)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Two pairs are inverted, and both have consequences:
|
||||||
|
|
||||||
|
- **Zod defaults beat `env`, not the other way round.** So `README.md:114` —
|
||||||
|
"allowing users to override them globally via `.nopyrc.json`" — is backwards.
|
||||||
|
Setting `"env": {"UPDATE": false}` cannot override a cube declaring
|
||||||
|
`.default(true)`; the `env` value is only visible for keys the schema does not
|
||||||
|
define. This is why `KEY_DIR` works in `packages/nopy/.nopyrc.json` (no cube
|
||||||
|
declares it) and why anything else would not.
|
||||||
|
- **Dependency/hook parameters beat user prompts.** A value passed as
|
||||||
|
`[['user:add', {USER: 'deploy'}]]` silently overrides what the operator just
|
||||||
|
typed at the form. The docs promise the opposite.
|
||||||
|
|
||||||
|
### 2.2 ✅ "Every key ... is guaranteed to be present on `host.data`" — not when a field lacks `.default()` — **fixed**
|
||||||
|
|
||||||
|
> **Resolved.** `getDefaults()` falls back to a per-field read, so one required
|
||||||
|
> field no longer wipes out the rest; `VariableAssignment` prompts for every
|
||||||
|
> schema key rather than only the defaulted ones; and `--use-defaults` refuses
|
||||||
|
> to deploy a cube whose required key nothing supplied. Verified against all 22
|
||||||
|
> cubes in `cubes/`: 19 build a complete `-D` run, the 3 below abort by name.
|
||||||
|
>
|
||||||
|
> Re-measured against the 25 cubes now in `packages/nopy-cubes-core/cubes`: 20 build a
|
||||||
|
> complete `-D` run and 5 abort by name. Two of the additions are deliberate —
|
||||||
|
> `user:add` lost its `PUBKEY` default (it was a specific personal key), and
|
||||||
|
> `ssh:keygen` inherits that failure because it declares `dependencies: () =>
|
||||||
|
> ['user:add']` and passes no parameters. See §6.6.
|
||||||
|
|
||||||
|
`README.md:99` states it outright; `README.md:105` claims defaults ensure "every
|
||||||
|
cube has a predictable starting state".
|
||||||
|
|
||||||
|
`Cube.getDefaults()` (`cubes/types.ts:106-112`) is:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
return this.manifest.schema.parse({});
|
||||||
|
} catch {
|
||||||
|
return {} as z.infer<Schema>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
One field without a `.default()` makes `parse({})` throw, and the `catch`
|
||||||
|
discards the defaults of **every other field in the cube**. `VariableAssignment`
|
||||||
|
then iterates over that empty object and returns before prompting
|
||||||
|
(`nopy.prompts.ts:175-181`), so the user is never asked. The cube deploys with no
|
||||||
|
`--data` flags at all and every `host.data.X` is `None`.
|
||||||
|
|
||||||
|
Three cubes in this repo are in that state today:
|
||||||
|
|
||||||
|
| Cube | Field(s) without `.default()` | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| `net:wifi:connection` | `SSID`, `PASSWORD` | no prompt, no `--data`, all 4 vars lost |
|
||||||
|
| `service:autostart` | `APP` | no prompt, no `--data`, all 3 vars lost |
|
||||||
|
| `user:edit` | `USER` | no prompt, no `--data`, all 4 vars lost |
|
||||||
|
|
||||||
|
The failure is silent — no error, no warning, just a pyinfra run with an empty
|
||||||
|
data set.
|
||||||
|
|
||||||
|
### 2.3 🔴 `.describe()` before `.default()` loses the prompt label
|
||||||
|
|
||||||
|
`CLAUDE.md` and the cube contract state that each schema field is `.describe()`d
|
||||||
|
and "the description is the prompt label". `nopy.prompts.ts:184-185` reads it as:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const zodType = schema[key];
|
||||||
|
const description = zodType?.description || key;
|
||||||
|
```
|
||||||
|
|
||||||
|
In zod 4, `.default()` returns a `ZodDefault` **wrapper** that does not inherit
|
||||||
|
`.description` from the type it wraps. Ordering therefore decides whether the
|
||||||
|
label survives:
|
||||||
|
|
||||||
|
```
|
||||||
|
z.boolean().describe('Update package cache').default(false) → description undefined
|
||||||
|
z.boolean().default(false).describe('Update package cache') → description preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
The README's own manifest example (`README.md:67-68`) uses the losing order, so
|
||||||
|
anyone copying it gets bare `UPDATE` / `PACKAGES` keys as prompt labels instead
|
||||||
|
of the sentences they wrote. `docs/API.md:610` happens to use the working order —
|
||||||
|
the two documents disagree, and neither mentions that it matters.
|
||||||
|
|
||||||
|
> `docs/API.md` now says so explicitly, next to its manifest example, with the
|
||||||
|
> zod 4.4.3 measurement (§3). The README example and the 15 affected manifests
|
||||||
|
> are untouched, and the one-line fix in `nopy.prompts.ts` — read through the
|
||||||
|
> `ZodDefault` wrapper — is still the better answer.
|
||||||
|
|
||||||
|
15 of the 22 cubes in `cubes/` are affected; among them
|
||||||
|
`net:tailscale` (all 4 fields), `runtime:nodevm` (all 4), `user:add` (all 4),
|
||||||
|
`ssh:keygen` (all 4) and `admin:locale` (all 4).
|
||||||
|
|
||||||
|
### 2.4 🟠 Session files claim `version` and `timestamp` fields
|
||||||
|
|
||||||
|
`README.md:179-181` shows a session with `"version": "1.0.0"` and
|
||||||
|
`"timestamp": "2025-10-13T10:30:00Z"`, and `docs/SESSION_FORMAT.md:305-306`
|
||||||
|
declares both **required** in the `NopySession` interface. Every MJS example in
|
||||||
|
that file sets them.
|
||||||
|
|
||||||
|
`NopySession` (`nopy.session.ts:44-55`) has neither. `createSession`
|
||||||
|
(`nopy.session.ts:183-197`) does not add them, `saveSession` writes the object
|
||||||
|
verbatim (`nopy.session.ts:68-79`), and `loadSession`'s validation
|
||||||
|
(`nopy.session.ts:146-155`) checks only `cubes`, `hosts` and `auth`. The
|
||||||
|
repository's own `packages/nopy/example.nopysession.json` omits both — it does
|
||||||
|
not match the format its own documentation prescribes.
|
||||||
|
|
||||||
|
Consequence: a `version` field implies a compatibility check that does not exist.
|
||||||
|
Nothing reads it, so an incompatible old session fails later and more obscurely
|
||||||
|
than a version check would.
|
||||||
|
|
||||||
|
### 2.5 🟠 Session filename convention does not match `listSessions()`
|
||||||
|
|
||||||
|
The READMEs consistently use `*.nopysession.json` (`README.md:223`, `330`, `338`;
|
||||||
|
`docs/DOCKER.md:54`; the shipped `example.nopysession.json`).
|
||||||
|
`docs/SESSION_FORMAT.md` consistently uses `*.session.json` / `*.session.mjs`.
|
||||||
|
|
||||||
|
`listSessions()` (`nopy.session.ts:173`) matches only the second form:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
|
||||||
|
```
|
||||||
|
|
||||||
|
`"my-deployment.nopysession.json"` does not end in `".session.json"`, so the
|
||||||
|
file naming the README recommends is invisible to the function documented at
|
||||||
|
`docs/API.md:430`. `loadSession` is unaffected (it switches on `.json`/`.mjs`),
|
||||||
|
so this only bites the listing API.
|
||||||
|
|
||||||
|
### 2.6 🟠 `docs/DOCKER.md` container name contradicts the file it points at
|
||||||
|
|
||||||
|
`docs/DOCKER.md:35` and `:45`:
|
||||||
|
|
||||||
|
> We explicitly name it `nopy-test-container` because the
|
||||||
|
> `example.nopysession.json` is configured to target this specific container name.
|
||||||
|
|
||||||
|
`packages/nopy/example.nopysession.json` targets `@docker/nopy-test-ubuntu` —
|
||||||
|
the *image* tag from the build step, not the container name. Following the guide
|
||||||
|
exactly produces a pyinfra run against a container that does not exist.
|
||||||
|
|
||||||
|
(`packages/nopy/.nopyrc.json` does list `@docker/nopy-test-container` in `hosts`,
|
||||||
|
so the guide was probably written against the config rather than the session.)
|
||||||
|
|
||||||
|
### 2.7 🟠 `docs/HOOKS.md` — hook parameters are not validated
|
||||||
|
|
||||||
|
`docs/HOOKS.md:48` describes the hook's second argument as "The final,
|
||||||
|
**validated** variables for the current cube".
|
||||||
|
|
||||||
|
`cubes/dependencies.ts:71` passes `this.variables.get(cubeId)` — a plain merge of
|
||||||
|
the four scopes. `schema.parse()` is called in exactly one place,
|
||||||
|
`Cube.getDefaults()` on an empty object, to extract defaults. Values from
|
||||||
|
prompts, `env`, dependencies and hooks are never validated against the schema at
|
||||||
|
any point in the pipeline. `coerceValue` (`nopy.prompts.ts:145-159`) type-coerces
|
||||||
|
prompt input, which is not the same as validation and does not apply to the other
|
||||||
|
three scopes.
|
||||||
|
|
||||||
|
### 2.8 🟠 `docs/HOOKS.md` — dependencies can pass variables too
|
||||||
|
|
||||||
|
The comparison table at `docs/HOOKS.md:83` says variable passing is
|
||||||
|
"Inherited from env" for dependencies versus "Explicitly passed via `exec()`" for
|
||||||
|
hooks, presenting explicit parameters as a hook-only capability.
|
||||||
|
|
||||||
|
`DependencySpec` is `string | [id, variables?]` (`cubes/types.ts:23`) and
|
||||||
|
`cubes/dependencies.ts:86-88` unpacks the tuple and forwards it into the same
|
||||||
|
`params` scope that `exec()` writes to. The two mechanisms are identical in this
|
||||||
|
respect; per §2.1 both outrank user prompts.
|
||||||
|
|
||||||
|
### 2.9 ✅ nopy README installation section describes the wrong package manager — **fixed**
|
||||||
|
|
||||||
|
> **Resolved.** The yarn-workspace block is gone. The section now opens with
|
||||||
|
> `npm install -g @bitsquare/nopy` (and the pnpm equivalent), documents the
|
||||||
|
> `latest` / `next` / `main` channels, shows the `@bitsquare` scope mapping
|
||||||
|
> needed to install from Gitea, and gains an *Upgrading* section covering
|
||||||
|
> `nopy self-update` and the `NOPY_*` env vars. The finding below is kept as the
|
||||||
|
> record of what was wrong.
|
||||||
|
|
||||||
|
`README.md:248-279` says "This package is part of a **yarn** workspace monorepo",
|
||||||
|
then gives `yarn install`, `yarn workspace @bitsquare/nopy build`,
|
||||||
|
`yarn workspace @bitsquare/nopy nopy`, and `yarn nopy`.
|
||||||
|
|
||||||
|
The repo is a **pnpm** workspace: `packageManager: "pnpm@11.17.0"` in the root
|
||||||
|
manifest, `pnpm-workspace.yaml`, a `pnpm-lock.yaml`, and every other document
|
||||||
|
(root `README.md`, `README.PUBLISH.md`, `CLAUDE.md`) uses pnpm. There is no
|
||||||
|
`yarn.lock`.
|
||||||
|
|
||||||
|
The section is also the wrong content for the file. `README.md` is one of three
|
||||||
|
files shipped in the npm tarball (`files: ["dist", "README.md", "LICENSE"]`), so
|
||||||
|
this is what a reader sees on npmjs.com — build-from-monorepo instructions
|
||||||
|
instead of `npm install -g @bitsquare/nopy`, which is what the root README and
|
||||||
|
`README.PUBLISH.md:314` correctly tell people to run.
|
||||||
|
|
||||||
|
### 2.10 🟠 keyman README: two operations missing, one operation invented
|
||||||
|
|
||||||
|
`packages/keyman/README.md:90-96` lists four menu entries: List, Encrypt,
|
||||||
|
Decrypt, Quit. The menu (`keyman.main.ts:54-61`) has six:
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 List keys 📝 Copy public key 🆕 Generate key
|
||||||
|
🔒 Encrypt keys 🔓 Decrypt keys ❌ Quit
|
||||||
|
```
|
||||||
|
|
||||||
|
`Copy public key` and `Generate key` are undocumented — the latter being the only
|
||||||
|
way to create a key inside the tool, which is why the Quick Start
|
||||||
|
(`packages/keyman/README.md:33`) tells the user to shell out to `ssh-keygen`
|
||||||
|
manually.
|
||||||
|
|
||||||
|
Conversely `packages/keyman/README.md:11` advertises "🔄 Support for key
|
||||||
|
rotation". There is no rotation anywhere: `grep -rn "rotat" packages/keyman/src/`
|
||||||
|
returns nothing.
|
||||||
|
|
||||||
|
`packages/keyman/README.md:93` also says encrypt takes keys "from `vault/tmp/`".
|
||||||
|
`encryptKeys` (`keyman.encrypt.ts:12-22`) unions `~/.ssh` and `vault/tmp`, and
|
||||||
|
offers both in the checkbox.
|
||||||
|
|
||||||
|
### 2.11 🟡 Root README understates the coverage gate
|
||||||
|
|
||||||
|
Root `README.md:57-58` describes "a hard **85 % branch** floor". Both
|
||||||
|
`vitest.config.ts` files set four thresholds: branches 85, functions 85, lines
|
||||||
|
80, statements 80. `README.PUBLISH.md:135` and `CLAUDE.md` both state all four —
|
||||||
|
the root README is the odd one out, and it is the file a new contributor reads
|
||||||
|
first.
|
||||||
|
|
||||||
|
### 2.12 🟡 `docs/DOCKER.md` relative link is broken
|
||||||
|
|
||||||
|
`docs/DOCKER.md:8` links `[README.md](./README.md)`, which resolves to
|
||||||
|
`packages/nopy/docs/README.md` — nonexistent. It should be `../README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ✅ `docs/API.md` — systematic drift — **fixed**
|
||||||
|
|
||||||
|
> **Resolved by regenerating the file**, which is what §3's own recommendation
|
||||||
|
> asked for — the drift was structural rather than a set of stale lines, so
|
||||||
|
> patching would have left the shape wrong. Every export in `src/index.ts` was
|
||||||
|
> re-read against its source and the file now covers all of them: the authoring
|
||||||
|
> package as its own section, `BuildContext` in place of the phantom Builder
|
||||||
|
> Module, and the variables, history and prompts modules that had no entry at
|
||||||
|
> all. The findings below are kept as the record of what was wrong.
|
||||||
|
>
|
||||||
|
> Three things were deliberately added rather than merely corrected. A
|
||||||
|
> **Known gaps** section states the behaviour a reader would otherwise take on
|
||||||
|
> trust — `logConfigToFlags` being unconsumed (§1.3), `--json` printing nothing
|
||||||
|
> on success (§1.2), the absent cycle detection (§1.5, §6.5), `DeployCall.dependencies`
|
||||||
|
> always being `[]`, `ExecutionResult.stdout`/`stderr` never being populated, and
|
||||||
|
> hook variables not being schema-validated (§2.7). The `.describe()`/`.default()`
|
||||||
|
> ordering hazard (§2.3) is called out where the manifest example lives, with the
|
||||||
|
> zod 4.4.3 measurement. And `-P` is documented alongside the rest of the CLI
|
||||||
|
> (§4.4 — the README half of that finding stands).
|
||||||
|
>
|
||||||
|
> One thing surfaced while writing it and is **not** fixed: `CubePackageRef` is
|
||||||
|
> referenced by the exported `NopyConfig` but is not itself re-exported from
|
||||||
|
> `src/index.ts`, so a consumer cannot name the type. Recorded in the file as a
|
||||||
|
> note.
|
||||||
|
|
||||||
|
`docs/API.md` documents an earlier architecture. It is not a matter of
|
||||||
|
individual stale lines: the two central type definitions, one whole module, and
|
||||||
|
two of the documented functions describe code that no longer exists. Anyone
|
||||||
|
building against this file writes code that will not compile.
|
||||||
|
|
||||||
|
Recommendation: regenerate rather than patch.
|
||||||
|
|
||||||
|
### 3.1 🔴 Functions that do not exist
|
||||||
|
|
||||||
|
| Documented | Reality |
|
||||||
|
|---|---|
|
||||||
|
| `resolveDependencies(cubes, selectedCubeNames)` (`API.md:142-160`) | No such export. Resolution is `BuildContext.resolveCube` and returns nothing — it accumulates into `deployCalls`. |
|
||||||
|
| `buildDeployCalls(cubeNames, hosts, context)` (`API.md:286-313`) | No such export. The entire "Builder Module" section, and its `BuildResult` interface, describes code replaced by `BuildContext` (`docs/REFACTORING.md` item 2). Still listed in the table of contents at `API.md:12`. |
|
||||||
|
|
||||||
|
### 3.2 🔴 `Cube<Schema>` — wrong shape entirely
|
||||||
|
|
||||||
|
`API.md:75-84` documents an interface with `key`, `dependencies: string[]`,
|
||||||
|
`schema`, `defaults()`, `before`, `after`.
|
||||||
|
|
||||||
|
`Cube` (`cubes/types.ts:88-113`) is a **class**: constructor `(manifest, dir,
|
||||||
|
deployScript)`, getters `id` and `name`, method `getDefaults()`. Everything else
|
||||||
|
lives behind `.manifest`. Not one documented member name is correct — `key` is
|
||||||
|
`id`, `defaults()` is `getDefaults()`, and `dependencies`/`schema`/`before`/
|
||||||
|
`after` are on `cube.manifest`, not on `cube`.
|
||||||
|
|
||||||
|
### 3.3 🔴 `Manifest<Schema>` — wrong shape
|
||||||
|
|
||||||
|
`API.md:92-100` documents `key`, `dependencies: string[]`, `defaults: () => ...`.
|
||||||
|
|
||||||
|
`Manifest` (`cubes/types.ts:43-56`) has `id` (not `key`), no `defaults` member at
|
||||||
|
all, and `dependencies` is a **function of the collected variables**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
|
||||||
|
```
|
||||||
|
|
||||||
|
That signature change is the headline of `docs/REFACTORING.md` item 2. The
|
||||||
|
`API.md` example at `:171` does use the function form, so the file contradicts
|
||||||
|
itself two paragraphs apart. Both `Cube` and `Manifest` are additionally shown
|
||||||
|
as generic over `z.AnyZodObject`, which zod 4 removed; the codebase defines
|
||||||
|
`AnyObjectSchema` for exactly this reason (`cubes/types.ts:13`).
|
||||||
|
|
||||||
|
### 3.4 🟠 Incorrect signatures and examples
|
||||||
|
|
||||||
|
| Location | Documented | Actual |
|
||||||
|
|---|---|---|
|
||||||
|
| `API.md:486-492` | `saveConfig(data, local?)`, example passes `false` | `saveConfig(data, configPath?: string)` (`nopy.config.ts:323`). Passing `false` writes nothing useful. |
|
||||||
|
| `API.md:481-484` | Search order `./nopyrc.json` then `~/.nopyrc.json` | Filename is `.nopyrc.json` (leading dot). Home is applied **first** (lowest priority, `nopy.config.ts:117-120`), all ancestors are collected and merged root-first, and the function **throws** when none is found (`nopy.config.ts:285-289`). The `resolution` merge strategy is not mentioned. |
|
||||||
|
| `API.md:534-540` | `VariableAssignment(cube, env)` returning vars | `VariableAssignment(cube, variables: Variables)` returns `Promise<void>` and mutates the `Variables` instance (`nopy.prompts.ts:167`). The example's return value is always `undefined`. |
|
||||||
|
| `API.md:321-331` | `runWorkflow(sessionPath, cubes, config, options?)` | Takes a fifth parameter `replaySession?: NopySession` (`nopy.workflow.ts:206-212`) — the entire history-replay path. |
|
||||||
|
| `API.md:337-344` | `WorkflowResult.cubesWithDependencies` | Field is `selectedCubes` (`nopy.workflow.ts:31`). |
|
||||||
|
| `API.md:202-209` | `DeployCall.dependencies: string[]` | `DependencySpec[]` (`nopy.executor.ts:27`) — and always `[]` in practice (`cubes/dependencies.ts:132`). |
|
||||||
|
| `API.md:452-457` | `NopyConfig` with 4 fields | Missing `history` and `execution` (`nopy.config.ts:68-81`). |
|
||||||
|
| `API.md:37-45` | 7 `NopyOptions` parameters | Missing `printOnly`, `replaySession`, `saveToHistory` (`nopy.main.ts:93-104`). |
|
||||||
|
| `API.md:169-175` | `cubes.Manifest` example | Omits `id`, the field that determines the cube's identity. |
|
||||||
|
|
||||||
|
### 3.5 🟡 Exported and undocumented
|
||||||
|
|
||||||
|
Public API in `src/index.ts` with no `API.md` entry: the entire history module
|
||||||
|
(`addToHistory`, `listHistory`, `getLastSession`, `getSessionById`,
|
||||||
|
`clearHistory`, `removeFromHistory`, `loadHistory`, `saveHistory`,
|
||||||
|
`getHistoryPath`, `formatHistoryList`, `HISTORY_FILE`, `DEFAULT_HISTORY_SIZE`,
|
||||||
|
plus `HistoryEntry` / `SessionHistory`), `BuildContext`,
|
||||||
|
`runSessionReplayWorkflow`, `getConfigPaths`, `findCubeDirectories`, `getCube`,
|
||||||
|
~~`filterInternalVariables`, `separateEnvAndCubeVariables`~~ — those last two were
|
||||||
|
dead on arrival (nothing called them once the session recorder stopped splitting
|
||||||
|
`env` out) and have since been deleted rather than documented. `maskCommand`,
|
||||||
|
`maskVariables`, `Variable`, `Variables`, `MASK` and the `Origin` / `Assignment`
|
||||||
|
types are newly exported and also have no `API.md` entry.
|
||||||
|
|
||||||
|
The CLI cheat-sheet (`API.md:554-578`) omits `-R`, `-H`, `-P`, `--no-history`,
|
||||||
|
and the `history` / `clear-history` commands.
|
||||||
|
|
||||||
|
`ManifestFactory` (`cubes/factories.ts:28`) is marked `@deprecated` but is not
|
||||||
|
re-exported from `cubes/index.ts`, so it is unreachable dead code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Undocumented behaviour
|
||||||
|
|
||||||
|
### 4.1 🔴 Cubes in this repo cannot be loaded
|
||||||
|
|
||||||
|
`CLAUDE.md` records the `@bitsquare/nopy` linking gotcha; the package README does
|
||||||
|
not mention it at all, and the gotcha is incomplete.
|
||||||
|
|
||||||
|
Cube manifests are loaded by dynamic `import()` from their own directory
|
||||||
|
(`cubes/loader.ts:75`), so they resolve their imports through ordinary Node
|
||||||
|
resolution from `cubes/…`. Nothing in the tree provides either dependency:
|
||||||
|
|
||||||
|
```
|
||||||
|
node_modules/@bitsquare/ → absent
|
||||||
|
packages/nopy/node_modules/@bitsquare/ → absent
|
||||||
|
cubes/node_modules/ → absent
|
||||||
|
```
|
||||||
|
|
||||||
|
`@bitsquare/nopy` is the documented half. **`zod` is the other half** — 20 of 22
|
||||||
|
manifests `import { z } from 'zod'`, and that fails independently of the nopy
|
||||||
|
link. Verified by loading every manifest with a resolver hook: with only
|
||||||
|
`@bitsquare/nopy` mapped, 20 of 22 fail `ERR_MODULE_NOT_FOUND: zod`.
|
||||||
|
|
||||||
|
Because `loadCubes` turns each failure into an `errors` entry and `nopy.main.ts:147-152`
|
||||||
|
aborts when `errors.length > 0`, a fresh clone cannot run a single cube. Neither
|
||||||
|
README mentions a setup step.
|
||||||
|
|
||||||
|
### 4.2 🟠 The SSH password is printed in plaintext — **mostly fixed**
|
||||||
|
|
||||||
|
> **Points 1 and 2 resolved; point 3 stands.** `maskCommand()`
|
||||||
|
> (`nopy.executor.ts`) rewrites the SSH `--password` and every `--data` value the
|
||||||
|
> manifest declared a secret, and it is applied at all three places the command
|
||||||
|
> string is printed: the debug log, the dry-run plan, and `--print-only`. The
|
||||||
|
> name heuristic described in point 2 is gone — the manifest's `secrets` array
|
||||||
|
> says which keys are sensitive, so `TOKEN`, `PSK` and `AUTH_KEY` are covered
|
||||||
|
> too, and it no longer matters that a key merely *looks* like a password.
|
||||||
|
>
|
||||||
|
> Point 3 is unchanged and now documented instead: the value still reaches
|
||||||
|
> pyinfra on its command line, so it is visible in `ps`. That is inherent to
|
||||||
|
> pyinfra's `--data` interface, not something nopy can mask. The shell-quoting
|
||||||
|
> concern in the same point is also still open. See `docs/REFACTORING.md` item 7.
|
||||||
|
|
||||||
|
Not stated in any document, and it sits directly against the security notes at
|
||||||
|
`README.md:217` and `:325` (which are narrowly about *storage*, and are correct
|
||||||
|
as far as they go).
|
||||||
|
|
||||||
|
`buildDeployCall` embeds the password in the command string
|
||||||
|
(`cubes/dependencies.ts:112-113`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
parts.push(`--user ${this.auth.username} --password ${this.auth.password}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
That string is then:
|
||||||
|
|
||||||
|
1. logged at debug level — `log.debug(\`Command: ${commandStr}\`)`
|
||||||
|
(`nopy.executor.ts:76`) — and the `nopy` logger is configured with
|
||||||
|
`lowestLevel: 'debug'` (`nopy.main.ts:41-44`), so it **prints to the console
|
||||||
|
on every deployment**;
|
||||||
|
2. printed unmasked by `--dry-run` — `outputExecutionPlan` masks values whose key
|
||||||
|
contains "password" in the `--data` section (`nopy.executor.ts:134`) but prints
|
||||||
|
`call.command.join(' ')` verbatim one line earlier (`nopy.executor.ts:127`);
|
||||||
|
3. passed through `execa({shell: true})` (`nopy.executor.ts:79`), making it
|
||||||
|
visible in the process list and, without quoting, vulnerable to shell
|
||||||
|
metacharacters in the password.
|
||||||
|
|
||||||
|
### 4.3 ✅ History and session files record only prompted values — **fixed**
|
||||||
|
|
||||||
|
> **Resolved.** `buildDeployCall` records `Variables.persistable(cubeId)` — every
|
||||||
|
> value the cube settled on, whatever its origin — so a `--use-defaults` run no
|
||||||
|
> longer records an empty object, and a replay reproduces the run rather than
|
||||||
|
> re-deriving it from whatever the defaults and `env` say at replay time. The one
|
||||||
|
> deliberate exclusion is a key the manifest declared a secret; those are prompted
|
||||||
|
> for again on replay, and a `-D` replay that would need one fails by name.
|
||||||
|
>
|
||||||
|
> The divergence noted at the end of this finding narrows but does not vanish: a
|
||||||
|
> dependency graph that resolves differently can still produce a different
|
||||||
|
> command, because `param` outranks `session` by design.
|
||||||
|
|
||||||
|
`README.md:414` says an entry records "the variable values that were answered at
|
||||||
|
the prompts" — accurate, but the consequence is not drawn out.
|
||||||
|
`buildDeployCall` records `this.variables.get(cubeId, 'prompts')`
|
||||||
|
(`cubes/dependencies.ts:138`) — the prompts scope alone. Values that came from a
|
||||||
|
dependency spec, a hook's `exec()`, `env`, or a schema default are **not** in the
|
||||||
|
entry.
|
||||||
|
|
||||||
|
Combined with §2.1 (dependency params outrank prompts) this means a replay can
|
||||||
|
legitimately produce a different command than the run it replays, if the
|
||||||
|
dependency graph resolved differently.
|
||||||
|
|
||||||
|
### 4.4 🟡 `-P, --print-only` is undocumented
|
||||||
|
|
||||||
|
Implemented (`nopy.cli.ts:61`, `nopy.main.ts:202-213`), listed in the CLI's own
|
||||||
|
help examples (`nopy.cli.ts:38`), and absent from `README.md` and `docs/API.md`.
|
||||||
|
It prints the built pyinfra commands grouped by cube and exits.
|
||||||
|
|
||||||
|
Worth documenting alongside `--dry-run`, since the difference is not obvious:
|
||||||
|
`--print-only` returns a `NopyResult` with `successful: 0` and skips execution
|
||||||
|
entirely, while `--dry-run` goes through the executor.
|
||||||
|
|
||||||
|
### 4.5 🟡 `--save-session` is ignored during a replay
|
||||||
|
|
||||||
|
`nopy.main.ts:191` guards with `saveSessionPath && !workflow.isReplay`, so
|
||||||
|
`nopy install -R -s out.json` writes nothing and says nothing. The
|
||||||
|
"Recording a Session" section (`README.md:219-227`) does not mention it.
|
||||||
|
|
||||||
|
### 4.6 🟡 `.npcubes` is documented but unused in this repo
|
||||||
|
|
||||||
|
`README.md:43` shows a `cubes/.npcubes` marker in the layout diagram and
|
||||||
|
`README.md:244` documents the mechanism. `find . -name .npcubes` returns nothing
|
||||||
|
— discovery here runs entirely off `cubeDirs` in `.nopyrc.json`. The feature
|
||||||
|
exists in `findCubeDirectories` (`cubes/loader.ts:26-30`); the diagram just shows
|
||||||
|
a file that no reader will find if they go looking.
|
||||||
|
|
||||||
|
### 4.7 🟡 `ssh:keyman` depends on a global `env` value
|
||||||
|
|
||||||
|
`cubes/ssh/keyman/deploy.py` reads `host.data.get('KEY_DIR')`, which no manifest
|
||||||
|
declares — it comes from `env` in `.nopyrc.json`. This works (§2.1: `env` is
|
||||||
|
visible for keys the schema does not define) and it is the only cube relying on
|
||||||
|
the mechanism, but nothing documents the coupling. Anyone running that cube from
|
||||||
|
a project without `KEY_DIR` in their config gets `None`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cube documentation
|
||||||
|
|
||||||
|
Two cubes have **no README at all**: `cubes/admin/hostname` and `cubes/git/clone`
|
||||||
|
(20 of 22 have one).
|
||||||
|
|
||||||
|
### 5.1 🔴 `cubes/service/autostart/README.md` documents a different cube
|
||||||
|
|
||||||
|
The file is titled **"TypeStack Install Cube"** and describes cloning a git
|
||||||
|
repository, `yarn install`, `yarn build`, `docker compose up -d`, and PM2 process
|
||||||
|
management. The manifest (`service:autostart`, "Manage systemd service
|
||||||
|
autostart") does none of that — it has three fields and calls `systemd.service`.
|
||||||
|
|
||||||
|
| README documents | In the schema? |
|
||||||
|
|---|---|
|
||||||
|
| `USER` | ❌ |
|
||||||
|
| `REPO` | ❌ |
|
||||||
|
| `ENV` | ❌ |
|
||||||
|
| `NODE_PATH` | ❌ |
|
||||||
|
| `APP` | ✅ |
|
||||||
|
| `AUTOSTART` | ✅ |
|
||||||
|
| — | `SERVICE_NAME` (undocumented) |
|
||||||
|
|
||||||
|
Every "Requirements" entry (Git, Yarn, Docker, PM2, NVM, SSH keys) is inapplicable.
|
||||||
|
It reads as a leftover from a cube that was split or renamed.
|
||||||
|
|
||||||
|
### 5.2 🟠 `cubes/network/wifi/access-point/README.md` — four wrong parameters
|
||||||
|
|
||||||
|
| README | Manifest |
|
||||||
|
|---|---|
|
||||||
|
| `NETWORK_DEVICE` (default `wlan0`) | does not exist |
|
||||||
|
| `CHANNEL` | does not exist — **but `deploy.py` reads it** (see §6.3) |
|
||||||
|
| `IP_ADDRESS` (default `192.168.50.1`) | field is `AP_IP`, default `192.168.4.1` |
|
||||||
|
| `CONNECTION_NAME` default `net:wifi:ap` | default is `pi-point` |
|
||||||
|
| SSID / PASSWORD listed as "Required" | both have defaults (`PiPoint` / `1223334444`) |
|
||||||
|
|
||||||
|
Both worked examples set keys that will be ignored.
|
||||||
|
|
||||||
|
### 5.3 🟠 Two cubes claim to have no parameters
|
||||||
|
|
||||||
|
| Cube | README says | Schema has |
|
||||||
|
|---|---|---|
|
||||||
|
| `cubes/runtime/docker` | "This cube currently has no configurable parameters." | `DISTRO` (`ubuntu` \| `debian`) |
|
||||||
|
| `cubes/runtime/nodevm` | "This cube currently has no configurable parameters." | `VERSION`, `USER`, `ALIAS`, `GLOBAL_PACKAGES` |
|
||||||
|
|
||||||
|
`nodevm`'s README also describes installing "the latest LTS via the official
|
||||||
|
NodeSource setup script", while the manifest is named "Install nvm and nodejs
|
||||||
|
with global packages" and takes an explicit `VERSION`.
|
||||||
|
|
||||||
|
(`admin/cockpit` and `armor/fail2ban` make the same claim and are correct — both
|
||||||
|
have empty schemas.)
|
||||||
|
|
||||||
|
### 5.4 🟡 Cube id conventions are inconsistent
|
||||||
|
|
||||||
|
- `cubes/caddy/base` declares `id: 'caddy'` while its sibling declares
|
||||||
|
`caddy:spa`. Every other nested cube uses the `group:name` form.
|
||||||
|
- `net:wifi:connection` sets `name: 'network:wifi:connection - Connect to a WiFi
|
||||||
|
network'` and `user:edit` sets `name: 'user:edit - Modify an existing user
|
||||||
|
account'` — the id is baked into the display name. Since the picker renders
|
||||||
|
`${cube.id} - ${cube.name}` (`nopy.prompts.ts:43`), these show as
|
||||||
|
`net:wifi:connection - network:wifi:connection - Connect to a WiFi network`.
|
||||||
|
`README.md:54` documents `[id]`-in-name as a *fallback* for a missing `id`
|
||||||
|
field, not as a prefix to carry alongside one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Defects found while verifying
|
||||||
|
|
||||||
|
Not documentation issues, but found while checking the docs and worth recording.
|
||||||
|
|
||||||
|
### 6.1 🔴 `cubes/service/autostart/deploy.py` cannot run
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pyinfra.operations import systemd # `server` is never imported
|
||||||
|
from pyinfra import host
|
||||||
|
|
||||||
|
APP = host.data.APP # AUTOSTART and SERVICE_NAME never read
|
||||||
|
|
||||||
|
if AUTOSTART: # NameError
|
||||||
|
...
|
||||||
|
server.shell(...) # NameError, in the else branch
|
||||||
|
```
|
||||||
|
|
||||||
|
`AUTOSTART` and `SERVICE_NAME` are declared in the manifest and never pulled off
|
||||||
|
`host.data`; `server` is used but not imported. The script raises `NameError` on
|
||||||
|
the `if`. Per §2.2 this cube also gets no `--data` at all, so it fails twice over.
|
||||||
|
|
||||||
|
### 6.2 🔴 `-H <id>` and `--no-history` share one destination
|
||||||
|
|
||||||
|
Both options write to `options.history` (`nopy.cli.ts:57` and `:64`). Verified
|
||||||
|
with Commander:
|
||||||
|
|
||||||
|
```
|
||||||
|
argv=[] -> {} # undefined → saves
|
||||||
|
argv=["--no-history"] -> {"history":false} # correct
|
||||||
|
argv=["-H","abc123"] -> {"history":"abc123"} # correct
|
||||||
|
argv=["-H","abc123","--no-history"] -> {"history":false} # id destroyed
|
||||||
|
```
|
||||||
|
|
||||||
|
In the last case the `-H` argument is silently discarded and nopy falls through
|
||||||
|
to a full interactive run instead of replaying. `saveToHistory: options.history
|
||||||
|
!== false` (`nopy.cli.ts:104`) works only because the two meanings happen not to
|
||||||
|
collide in the common cases.
|
||||||
|
|
||||||
|
### 6.3 🟠 `access-point/deploy.py` reads an undeclared variable
|
||||||
|
|
||||||
|
`host.data.get('CHANNEL')` — no manifest declares `CHANNEL`, so it is always
|
||||||
|
`None`. The README documents it as a supported optional parameter (§5.2). One of
|
||||||
|
the three has to give.
|
||||||
|
|
||||||
|
### 6.4 🟡 Debug output left in the shipped code
|
||||||
|
|
||||||
|
- ~~`nopy.common.ts:22` — `console.log('Assigning', artefactId, scope, values)`
|
||||||
|
fires on every variable assignment, printing values to the console. Combined
|
||||||
|
with §4.2 this is a second path by which secrets reach stdout.~~ **Removed**
|
||||||
|
alongside the `--use-defaults` work; it would have made an unattended run
|
||||||
|
unreadable. The two other paths in §4.2 are untouched.
|
||||||
|
- `keyman.encrypt.ts:19-20` — `console.log(tmpKeys); console.log(sshKeys);`
|
||||||
|
before the prompt.
|
||||||
|
|
||||||
|
### 6.5 🟡 No cycle detection
|
||||||
|
|
||||||
|
Covered under §1.5. `docs/API.md:160` documents the error; there is no code that
|
||||||
|
raises it. Mutually dependent cubes recurse until the stack overflows.
|
||||||
|
|
||||||
|
### 6.6 🟠 `ssh:keygen` depends on `user:add` but shares nothing with it
|
||||||
|
|
||||||
|
`ssh:keygen` declares `dependencies: () => ['user:add']` with no parameters, so
|
||||||
|
the two cubes resolve their `USER` independently:
|
||||||
|
|
||||||
|
- `ssh:keygen` defaults `USER` to `vagrant`;
|
||||||
|
- `user:add` defaults `USER` to `` user${uniqid(5)} `` — a fresh random name.
|
||||||
|
|
||||||
|
So the dependency creates an account the dependent then ignores, and generates a
|
||||||
|
key for a `vagrant` user it never created. Passing the value through
|
||||||
|
(`[['user:add', {USER}]]`) is what the dependency spec exists for; `param`
|
||||||
|
outranks `default`, so it would take effect.
|
||||||
|
|
||||||
|
Surfaced by removing `user:add`'s `PUBKEY` default: `ssh:keygen` now fails a `-D`
|
||||||
|
run with `Cube "user:add" cannot run with --use-defaults: PUBKEY has no default
|
||||||
|
value`, naming a cube the operator did not select. The underlying mismatch is
|
||||||
|
older than that change and is not fixed here.
|
||||||
|
|
||||||
|
Related: `user:add`'s `USER` default is generated (`` user${uniqid(5)} ``), the
|
||||||
|
same shape as the `PASSWORD` default that was removed. It is recorded in the
|
||||||
|
session, so replays are stable, but each fresh `-D` run still creates a
|
||||||
|
differently-named account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Checked and accurate
|
||||||
|
|
||||||
|
Recording what was verified and found correct, so a future pass need not redo it.
|
||||||
|
|
||||||
|
- **`README.PUBLISH.md`** — checked against `.gitea/workflows/*.yml` and both
|
||||||
|
manifests. Workflow triggers, the `files` array, dist-tag rules, the snapshot
|
||||||
|
version format, `upload-artifact@v3`, `cache@v4`, the `npm pack --dry-run`
|
||||||
|
step, `retention-days: 7`, and all four coverage thresholds are right. The
|
||||||
|
only file that states the coverage gate completely.
|
||||||
|
- **Root `README.md`** — pnpm/corepack, Node ≥ 22 with `.nvmrc` pinning 24, the
|
||||||
|
script table, and both git hooks match the root `package.json`. Only the
|
||||||
|
coverage line is incomplete (§2.11).
|
||||||
|
- **`CLAUDE.md`** — accurate throughout, including the `logConfigToFlags` drift
|
||||||
|
note and the resolution/merge description. Two additions worth making: `zod` is
|
||||||
|
missing from the cube-linking gotcha (§4.1), and `-D` being a no-op (§1.1)
|
||||||
|
belongs under "Known drift".
|
||||||
|
- **`README.md` history section** (`:392-429`) — the recording rules, the
|
||||||
|
fail-then-`-R` flow, replay-does-not-re-record, the four suppression cases, the
|
||||||
|
`defaults`-layer replay semantics, and the `Cube not found` failure mode all
|
||||||
|
check out against `nopy.history.ts`, `nopy.main.ts:195-200` and
|
||||||
|
`cubes/dependencies.ts:62-69`.
|
||||||
|
- **`README.md` continue-on-error section** (`:369-390`) — fail-fast, no
|
||||||
|
rollback, skipped-cubes-absent-from-results, exit code 1, and CLI-over-config
|
||||||
|
precedence match `nopy.executor.ts:180-193` and `nopy.cli.ts:67-68`.
|
||||||
|
- **`README.md` cube layout and discovery** — the directory-pair rule, recursive
|
||||||
|
scan, dotted/`node_modules` skipping, the prefixed `*.manifest.mjs` fallback,
|
||||||
|
and the three-step id resolution match `cubes/loader.ts` exactly.
|
||||||
|
- **pyinfra `--data` type coercion** (`README.md:101`) — correct.
|
||||||
|
- **keyman config** — priority (`VAULT_ROOT` > file > defaults), the four default
|
||||||
|
values, and the vault layout match `keyman.config.ts` and `keyman.encrypt.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order of attack
|
||||||
|
|
||||||
|
**1 — ~~Decide on the three phantom features.~~ Two left.** §1.1 (`-D`) is
|
||||||
|
**done** — implemented, tested, and verified against every cube in `cubes/`.
|
||||||
|
That closed §2.2 and half of §2.1 with it, since neither could be left standing
|
||||||
|
under a run that never prompts. §1.2 (`--json`) and §1.3 (`log.*`) are still
|
||||||
|
"documented, wired up, never read": each is a small implementation or a small
|
||||||
|
deletion, but neither can stay documented as working.
|
||||||
|
|
||||||
|
**4 — Decide the `.describe()`/`.default()` ordering (§2.3).** Either read
|
||||||
|
through the `ZodDefault` wrapper in `nopy.prompts.ts`, or fix the ordering in all
|
||||||
|
14 manifests and the README example. The first is one line and cannot regress.
|
||||||
|
|
||||||
|
**5 — ~~Regenerate `docs/API.md` (§3).~~ Done.** Rewritten against the source
|
||||||
|
rather than patched, and extended to the exports that never had an entry
|
||||||
|
(variables, history, prompts, the authoring package). One new finding came out of
|
||||||
|
it: `CubePackageRef` is not re-exported from `src/index.ts` although `NopyConfig`
|
||||||
|
refers to it — a one-line fix, left for whoever next touches the export list.
|
||||||
|
|
||||||
|
**6 — Cube docs (§5) and the two missing READMEs.** `service/autostart` is the
|
||||||
|
worst — its README belongs to a different cube, and its `deploy.py` does not run
|
||||||
|
at all (§6.1).
|
||||||
|
|
||||||
|
**7 — Secrets on stdout (§4.2, §6.4).** The `console.log` in `Variables.assign`
|
||||||
|
is gone. Still open: mask the password in the executor's debug line and in the
|
||||||
|
dry-run plan, and pass `--user`/`--password` as argv rather than interpolating
|
||||||
|
into a shell string.
|
||||||
+375
-44
@@ -14,6 +14,9 @@ shipped. If you only want to cut a release, jump to
|
|||||||
- [Secrets](#secrets)
|
- [Secrets](#secrets)
|
||||||
- [Registry authentication in the workflows](#registry-authentication-in-the-workflows)
|
- [Registry authentication in the workflows](#registry-authentication-in-the-workflows)
|
||||||
- [Installing the packages](#installing-the-packages)
|
- [Installing the packages](#installing-the-packages)
|
||||||
|
- [Resolving from Gitea in this repo](#resolving-from-gitea-in-this-repo)
|
||||||
|
- [Testing a snapshot before you release](#testing-a-snapshot-before-you-release)
|
||||||
|
- [Upgrading an installed CLI](#upgrading-an-installed-cli)
|
||||||
- [Design decisions](#design-decisions)
|
- [Design decisions](#design-decisions)
|
||||||
- [Checking things locally](#checking-things-locally)
|
- [Checking things locally](#checking-things-locally)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
@@ -21,19 +24,44 @@ shipped. If you only want to cut a release, jump to
|
|||||||
|
|
||||||
## What ships
|
## What ships
|
||||||
|
|
||||||
| Directory | Package | Binary |
|
| Directory | Package | Binary | Kind |
|
||||||
| ----------------- | ------------------ | -------- |
|
| --------------------- | ----------------------- | -------- | ------------------------ |
|
||||||
| `packages/nopy` | `@bitstack/nopy` | `nopy` |
|
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | CLI |
|
||||||
| `packages/keyman` | `@bitstack/keyman` | `keyman` |
|
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | CLI |
|
||||||
|
| `packages/nopy-cubes` | `@bitsquare/nopy-cubes` | — | library (cube authoring) |
|
||||||
|
| `packages/nopy-cubes-core` | `@bitsquare/nopy-cubes-core` | — | cube bundle (no build) |
|
||||||
|
|
||||||
Both are ESM, both declare `engines.node >= 22`, and both expose a single
|
All are ESM and declare `engines.node >= 22`. The two CLIs expose a single
|
||||||
executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the
|
executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the
|
||||||
`PATH`. `cubes/` is not a package and is never published.
|
`PATH`; the other two are libraries you add to a project.
|
||||||
|
|
||||||
The tarball contents are pinned by `files: ["dist", "README.md", "LICENSE"]` —
|
The tarball contents are pinned by `files` — for the three TypeScript packages
|
||||||
sources and tests are not shipped. `publishConfig.access: "public"` is what makes
|
that is `["dist", "README.md", "LICENSE"]`, so sources and tests are not shipped.
|
||||||
a scoped package publishable to npmjs without an extra flag; the workflows pass
|
`nopy-cubes-core` ships `["cubes", "!cubes/**/*.log", "README.md", "LICENSE"]`: the
|
||||||
`--access public` anyway.
|
negation matters, because a cube that has been run leaves a `pyinfra-debug.log`
|
||||||
|
next to its `deploy.py`, and `.gitignore` does not filter an npm tarball.
|
||||||
|
`publishConfig.access: "public"` is what makes a scoped package publishable to
|
||||||
|
npmjs without an extra flag; the workflows pass `--access public` anyway.
|
||||||
|
|
||||||
|
### Dependencies between them
|
||||||
|
|
||||||
|
`keyman` stands alone. `nopy` and `nopy-cubes-core` both depend on `nopy-cubes` through
|
||||||
|
`workspace:*`, which drives three rules the rest of this document keeps coming
|
||||||
|
back to:
|
||||||
|
|
||||||
|
1. **Publish with `pnpm`, not `npm`.** `link-workspace-packages` is unset and
|
||||||
|
pnpm 10+ defaults it to `false`, so `workspace:*` is mandatory in the
|
||||||
|
manifests. npm has no idea what that protocol is: `npm pack` copies the string
|
||||||
|
through verbatim and the install fails with `EUNSUPPORTEDPROTOCOL`. `pnpm
|
||||||
|
pack` and `pnpm publish` substitute the concrete version at pack time. Both
|
||||||
|
workflows use `pnpm publish --ignore-scripts --no-git-checks`.
|
||||||
|
2. **`nopy-cubes` publishes before anything that depends on it.**
|
||||||
|
`node scripts/publish-order.mjs` prints the publishable directories in
|
||||||
|
dependency order — note that plain alphabetical `packages/*/` gets this
|
||||||
|
backwards, putting `nopy` first.
|
||||||
|
3. **Every packed manifest is checked.** `node scripts/verify-pack.mjs` packs
|
||||||
|
each non-private package and fails if any `workspace:` range survived into the
|
||||||
|
tarball. It runs in both publish workflows, after the build.
|
||||||
|
|
||||||
Versions and changelogs are maintained **by hand**. Nothing in CI commits a
|
Versions and changelogs are maintained **by hand**. Nothing in CI commits a
|
||||||
version bump, opens a release PR, or pushes a tag. A release happens because you
|
version bump, opens a release PR, or pushes a tag. A release happens because you
|
||||||
@@ -47,7 +75,7 @@ All three live in [`.gitea/workflows`](.gitea/workflows) and run on the
|
|||||||
| Workflow | Trigger | Publishes |
|
| Workflow | Trigger | Publishes |
|
||||||
| ---------------------- | -------------------------------- | ------------------------------------------ |
|
| ---------------------- | -------------------------------- | ------------------------------------------ |
|
||||||
| `ci.yml` | pull requests, non-`main` pushes | nothing |
|
| `ci.yml` | pull requests, non-`main` pushes | nothing |
|
||||||
| `publish-snapshot.yml` | pushes to `main` | **both** packages → Gitea, tag `main` |
|
| `publish-snapshot.yml` | pushes to `main` | **every** package → Gitea, tag `main` |
|
||||||
| `release.yml` | tags matching `*-v*` | **one** package → Gitea **and** npmjs |
|
| `release.yml` | tags matching `*-v*` | **one** package → Gitea **and** npmjs |
|
||||||
|
|
||||||
`ci.yml` explicitly excludes `main` and all tags (`branches-ignore` +
|
`ci.yml` explicitly excludes `main` and all tags (`branches-ignore` +
|
||||||
@@ -64,13 +92,17 @@ half-finished publish is worse than a slow queue.
|
|||||||
```
|
```
|
||||||
checkout → pnpm → node → pnpm store cache → install
|
checkout → pnpm → node → pnpm store cache → install
|
||||||
→ lint:ci → typecheck → test:coverage → coverage summary
|
→ lint:ci → typecheck → test:coverage → coverage summary
|
||||||
→ build → npm pack --dry-run (per package) → upload coverage
|
→ build → npm pack --dry-run (per package) → verify-pack
|
||||||
|
→ upload coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
The `npm pack --dry-run --ignore-scripts` step prints the exact file list that
|
The `npm pack --dry-run --ignore-scripts` step prints the exact file list that
|
||||||
would be published. It is there to catch a `files` or `bin` entry pointing at
|
would be published. It is there to catch a `files` or `bin` entry pointing at
|
||||||
something the build no longer produces — a failure that would otherwise only
|
something the build no longer produces — a failure that would otherwise only
|
||||||
surface after the version is already on a registry and immutable.
|
surface after the version is already on a registry and immutable. `verify-pack`
|
||||||
|
then packs for real and checks no `workspace:` range survived; both publish
|
||||||
|
workflows run it too, but running it here is what puts the failure on the pull
|
||||||
|
request rather than on the release.
|
||||||
|
|
||||||
Coverage HTML/JSON reports are uploaded as a `coverage` artifact with a 7-day
|
Coverage HTML/JSON reports are uploaded as a `coverage` artifact with a 7-day
|
||||||
retention. Both the artifact upload and the store cache are
|
retention. Both the artifact upload and the store cache are
|
||||||
@@ -82,18 +114,25 @@ slower CI rather than broken CI.
|
|||||||
```
|
```
|
||||||
checkout → pnpm → node → cache → install
|
checkout → pnpm → node → cache → install
|
||||||
→ lint:ci → typecheck → test:coverage → coverage summary → build
|
→ lint:ci → typecheck → test:coverage → coverage summary → build
|
||||||
→ write .npmrc → publish both packages → delete .npmrc
|
→ verify-pack → write .npmrc → publish every package → delete .npmrc
|
||||||
```
|
```
|
||||||
|
|
||||||
One job, no `needs:` barrier, so install and build happen exactly once and
|
One job, no `needs:` barrier, so install and build happen exactly once and
|
||||||
nothing has to be passed between jobs as an artifact.
|
nothing has to be passed between jobs as an artifact.
|
||||||
|
|
||||||
|
The publish step is **two passes** over `node scripts/publish-order.mjs`: the
|
||||||
|
first stamps the snapshot version into every manifest with `npm pkg set`, the
|
||||||
|
second publishes. They cannot be one loop — `pnpm publish` reads a linked
|
||||||
|
package's version out of its manifest at pack time, so stamping and publishing
|
||||||
|
one package at a time would bake the *old* `nopy-cubes` version into `nopy`'s
|
||||||
|
tarball.
|
||||||
|
|
||||||
### `release.yml`
|
### `release.yml`
|
||||||
|
|
||||||
```
|
```
|
||||||
checkout → resolve tag → check secrets
|
checkout → resolve tag → check secrets
|
||||||
→ pnpm → node → cache → install
|
→ pnpm → node → cache → install → check linked deps are released
|
||||||
→ lint:ci → typecheck → test:coverage → build
|
→ lint:ci → typecheck → test:coverage → build → verify-pack
|
||||||
→ publish to Gitea → publish to npmjs → delete .npmrc
|
→ publish to Gitea → publish to npmjs → delete .npmrc
|
||||||
→ create the Gitea release → step summary
|
→ create the Gitea release → step summary
|
||||||
```
|
```
|
||||||
@@ -102,6 +141,14 @@ Tag resolution and the secret check run **before** anything is installed or
|
|||||||
built, so a malformed tag or a missing token fails in seconds instead of after
|
built, so a malformed tag or a missing token fails in seconds instead of after
|
||||||
the whole gate.
|
the whole gate.
|
||||||
|
|
||||||
|
*Check linked deps are released* asks npmjs whether every `workspace:` dependency
|
||||||
|
of the package being released already exists at the version pnpm is about to
|
||||||
|
bake in (`scripts/linked-deps.mjs` → `npm view`). Tagging `nopy-v1.3.0` while
|
||||||
|
`@bitsquare/nopy-cubes@1.1.0` is still unpublished would otherwise ship a tarball
|
||||||
|
nobody can install, and npmjs only lets you unpublish for 72 hours. The check is
|
||||||
|
npmjs-only: it runs before any credentials are written, and npmjs is the registry
|
||||||
|
where the mistake is permanent.
|
||||||
|
|
||||||
## The verification gate
|
## The verification gate
|
||||||
|
|
||||||
The same three commands guard every path into a registry:
|
The same three commands guard every path into a registry:
|
||||||
@@ -132,18 +179,43 @@ and `continue-on-error: true` — and can never be the reason a run goes red.
|
|||||||
|
|
||||||
| Source | Version | Registry | dist-tag |
|
| Source | Version | Registry | dist-tag |
|
||||||
| ----------------------------------------- | ----------------------------- | ------------ | -------- |
|
| ----------------------------------------- | ----------------------------- | ------------ | -------- |
|
||||||
| push to `main` | `1.0.0-main.42.g736c012` | Gitea | `main` |
|
| push to `main` | `0.5.0-main.42.g736c012` | Gitea | `main` |
|
||||||
| tag `nopy-v1.2.0` | `1.2.0` | Gitea, npmjs | `latest` |
|
| tag `nopy-v0.6.0` | `0.6.0` | Gitea, npmjs | `latest` |
|
||||||
| tag `nopy-v1.2.0-rc.1` | `1.2.0-rc.1` | Gitea, npmjs | `next` |
|
| tag `nopy-v0.6.0-rc.1` | `0.6.0-rc.1` | Gitea, npmjs | `next` |
|
||||||
|
|
||||||
The rule for the dist-tag is mechanical: a version containing a prerelease part
|
The rule for the dist-tag is mechanical: a version containing a prerelease part
|
||||||
(anything with a `-` in it) goes out as `next` and is marked as a prerelease on
|
(anything with a `-` in it) goes out as `next` and is marked as a prerelease on
|
||||||
the Gitea release; anything else goes out as `latest`. There is no way to publish
|
the Gitea release; anything else goes out as `latest`. There is no way to publish
|
||||||
a prerelease over `latest` by accident.
|
a prerelease over `latest` by accident.
|
||||||
|
|
||||||
|
### Why 0.x and not 1.0.0-alphaN
|
||||||
|
|
||||||
|
The packages used to be numbered `1.0.0-alpha5`, `1.0.0-alpha0` and so on. Every
|
||||||
|
one of those is a prerelease, so the rule above sent every release to `next` and
|
||||||
|
**`latest` never moved**. That is a quiet failure rather than a loud one: on
|
||||||
|
npmjs `latest` happened to point at `1.0.0-alpha5` only because npmjs sets
|
||||||
|
`latest` on a package's *first* publish whatever `--tag` says, and it would have
|
||||||
|
stayed pinned there through every subsequent alpha. On Gitea, which has no such
|
||||||
|
fallback, `latest` did not exist at all — and `npm view @bitsquare/nopy` against
|
||||||
|
a registry with no `latest` prints nothing and exits **0**, so it looks like a
|
||||||
|
successful lookup of a package with no data.
|
||||||
|
|
||||||
|
`0.x.y` says the same thing about stability that `1.0.0-alphaN` was trying to
|
||||||
|
say, while leaving the prerelease slot free for actual release candidates. So
|
||||||
|
`latest` rolls on every release, `next` means what it says, and no dist-tag has
|
||||||
|
to be moved by hand.
|
||||||
|
|
||||||
|
> **One-off consequence of the switch.** `1.0.0-alpha5` is semver-*greater* than
|
||||||
|
> any `0.x`, and it is already on npmjs. Publishing `0.5.0` moves the `latest`
|
||||||
|
> tag to it correctly, but the alpha remains the numerically highest version on
|
||||||
|
> the registry. Install with an explicit tag (`npm i -g @bitsquare/nopy@latest`,
|
||||||
|
> which follows the tag and will downgrade), not with `npm update -g`. Consider
|
||||||
|
> `npm deprecate '@bitsquare/nopy@1.0.0-alpha5' 'Superseded by the 0.x line'` so
|
||||||
|
> nobody lands on it by pinning.
|
||||||
|
|
||||||
## Snapshots
|
## Snapshots
|
||||||
|
|
||||||
Every commit that lands on `main` publishes both packages to the Gitea registry,
|
Every commit that lands on `main` publishes every package to the Gitea registry,
|
||||||
versioned as:
|
versioned as:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -155,7 +227,7 @@ semver even when the abbreviated sha happens to be all digits. The run number is
|
|||||||
monotonic, so every push produces a version that has never existed before.
|
monotonic, so every push produces a version that has never existed before.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm add @bitstack/nopy@main
|
pnpm add @bitsquare/nopy@main
|
||||||
```
|
```
|
||||||
|
|
||||||
Snapshots never reach npmjs and never move `latest`. The version is written into
|
Snapshots never reach npmjs and never move `latest`. The version is written into
|
||||||
@@ -175,7 +247,34 @@ edit is discarded with the workspace and is never committed.
|
|||||||
```
|
```
|
||||||
|
|
||||||
The tag name is `<directory>-v<version>` — the directory under `packages/`, not
|
The tag name is `<directory>-v<version>` — the directory under `packages/`, not
|
||||||
the npm name. `nopy-v1.2.0`, not `@bitstack/nopy-v1.2.0`.
|
the npm name. `nopy-v1.2.0`, not `@bitsquare/nopy-v1.2.0`. All four prefixes work
|
||||||
|
the same way:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag nopy-v1.2.0
|
||||||
|
git tag keyman-v1.2.0
|
||||||
|
git tag nopy-cubes-v1.2.0
|
||||||
|
git tag nopy-cubes-core-v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ordering when more than one package changed
|
||||||
|
|
||||||
|
Tags are independent, but the dependency graph is not. If a release touches
|
||||||
|
`nopy-cubes` *and* something that depends on it, release them in this order,
|
||||||
|
waiting for each run to go green:
|
||||||
|
|
||||||
|
```
|
||||||
|
nopy-cubes → nopy, nopy-cubes-core (these two are independent of each other)
|
||||||
|
```
|
||||||
|
|
||||||
|
Release `nopy` first and the run stops at the *check linked deps* step, telling
|
||||||
|
you the `nopy-cubes` version it wanted is not on npmjs. That is the guard working;
|
||||||
|
release `nopy-cubes`, then re-tag. `node scripts/publish-order.mjs` prints the
|
||||||
|
order if you would rather not reason about it.
|
||||||
|
|
||||||
|
Bumping `nopy-cubes` means bumping the packages that depend on it in the same
|
||||||
|
change — the `workspace:*` range resolves to whatever version is in the workspace
|
||||||
|
at pack time, so their next release picks it up whether or not you meant it to.
|
||||||
|
|
||||||
The tag decides **which** package ships; `package.json` decides the **version**.
|
The tag decides **which** package ships; `package.json` decides the **version**.
|
||||||
The workflow re-reads the manifest and refuses to continue if the two disagree:
|
The workflow re-reads the manifest and refuses to continue if the two disagree:
|
||||||
@@ -193,7 +292,7 @@ a coincidence, not a requirement.
|
|||||||
|
|
||||||
What a successful run leaves behind:
|
What a successful run leaves behind:
|
||||||
|
|
||||||
- `@bitstack/<pkg>@<version>` on the Gitea registry
|
- `@bitsquare/<pkg>@<version>` on the Gitea registry
|
||||||
- the same tarball on npmjs, public, under `latest` or `next`
|
- the same tarball on npmjs, public, under `latest` or `next`
|
||||||
- a Gitea release on the tag, with notes and an install snippet
|
- a Gitea release on the tag, with notes and an install snippet
|
||||||
- a step summary with both install commands
|
- a step summary with both install commands
|
||||||
@@ -228,7 +327,7 @@ organisation to share across repos.
|
|||||||
|
|
||||||
| Secret | Required | Purpose |
|
| Secret | Required | Purpose |
|
||||||
| ----------------- | -------- | --------------------------------------------------------- |
|
| ----------------- | -------- | --------------------------------------------------------- |
|
||||||
| `NPM_TOKEN` | yes | npmjs granular token, read-and-write on `@bitstack/*` |
|
| `NPM_TOKEN` | yes | npmjs granular token, read-and-write on `@bitsquare/*` |
|
||||||
| `MYGITEA_NPM_TOKEN` | yes | Gitea PAT with `write:package` |
|
| `MYGITEA_NPM_TOKEN` | yes | Gitea PAT with `write:package` |
|
||||||
|
|
||||||
`GITEA_TOKEN` is injected into every run by Gitea itself, and the workflows fall
|
`GITEA_TOKEN` is injected into every run by Gitea itself, and the workflows fall
|
||||||
@@ -241,12 +340,12 @@ practice. Create it under **Settings → Applications → Access Tokens** with t
|
|||||||
`package` scope set to read-and-write; its owner needs package-write on the
|
`package` scope set to read-and-write; its owner needs package-write on the
|
||||||
`BitSquare` organisation, since the registry path is org-owned.
|
`BitSquare` organisation, since the registry path is org-owned.
|
||||||
|
|
||||||
For npmjs, create a **granular access token** scoped to `@bitstack/*` with
|
For npmjs, create a **granular access token** scoped to `@bitsquare/*` with
|
||||||
read-and-write permission, and set 2FA to not-required so it works
|
read-and-write permission, and set 2FA to not-required so it works
|
||||||
unattended. npm warns against that combination and points at Trusted Publishing
|
unattended. npm warns against that combination and points at Trusted Publishing
|
||||||
instead — but Trusted Publishing federates only GitHub Actions and GitLab CI/CD
|
instead — but Trusted Publishing federates only GitHub Actions and GitLab CI/CD
|
||||||
over OIDC, and Gitea is not a provider it accepts. A token is the only route
|
over OIDC, and Gitea is not a provider it accepts. A token is the only route
|
||||||
from this runner. Scoping the token to `@bitstack/*` is what keeps the exposure
|
from this runner. Scoping the token to `@bitsquare/*` is what keeps the exposure
|
||||||
small: a leak lets someone publish to that scope, not touch the account.
|
small: a leak lets someone publish to that scope, not touch the account.
|
||||||
|
|
||||||
> npm caps granular token lifetime at 90 days, so `NPM_TOKEN` needs rotating
|
> npm caps granular token lifetime at 90 days, so `NPM_TOKEN` needs rotating
|
||||||
@@ -264,7 +363,7 @@ small: a leak lets someone publish to that scope, not touch the account.
|
|||||||
|
|
||||||
## Registry authentication in the workflows
|
## Registry authentication in the workflows
|
||||||
|
|
||||||
`release.yml` has to talk to two different registries about the same `@bitstack`
|
`release.yml` has to talk to two different registries about the same `@bitsquare`
|
||||||
scope inside one job. It does that without ever mutating `~/.npmrc`:
|
scope inside one job. It does that without ever mutating `~/.npmrc`:
|
||||||
|
|
||||||
- each publish step writes its own credentials file, created with
|
- each publish step writes its own credentials file, created with
|
||||||
@@ -281,23 +380,32 @@ file written into the workspace can never be committed by accident.
|
|||||||
|
|
||||||
## Installing the packages
|
## Installing the packages
|
||||||
|
|
||||||
From npmjs — public, no configuration:
|
From npmjs — public, no configuration. The CLIs go on the `PATH`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install -g @bitstack/nopy @bitstack/keyman
|
npm install -g @bitsquare/nopy @bitsquare/keyman
|
||||||
|
```
|
||||||
|
|
||||||
|
The other two go into a project. A cube bundle is a dev dependency of whatever
|
||||||
|
repo describes your infrastructure; `nopy-cubes` is only needed if you are writing
|
||||||
|
cubes of your own:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add -D @bitsquare/nopy-cubes-core # then name it in .nopyrc.json cubePackages
|
||||||
|
pnpm add -D @bitsquare/nopy-cubes zod # authoring your own manifests
|
||||||
```
|
```
|
||||||
|
|
||||||
From the Gitea registry, which holds every snapshot plus a mirror of every
|
From the Gitea registry, which holds every snapshot plus a mirror of every
|
||||||
release. Per-project, in the repo's `.npmrc`:
|
release. Per-project, in the repo's `.npmrc`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
```
|
```
|
||||||
|
|
||||||
Globally with credentials, in `~/.npmrc`:
|
Globally with credentials, in `~/.npmrc`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
//gitea.bitsquare.dev/api/packages/BitSquare/npm/:_authToken=<your gitea token>
|
//gitea.bitsquare.dev/api/packages/BitSquare/npm/:_authToken=<your gitea token>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -308,9 +416,173 @@ instance and organisation automatically.
|
|||||||
To track snapshots in another project:
|
To track snapshots in another project:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm add @bitstack/nopy@main
|
pnpm add @bitsquare/nopy@main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Always map the **scope**, never set a bare `registry=`. The Gitea registry
|
||||||
|
> serves `@bitsquare` packages and does not proxy npmjs, so a global
|
||||||
|
> `--registry` sends `commander`, `execa`, `zod` and everything else to a
|
||||||
|
> registry that has never heard of them. The CLI's own `self-update` builds
|
||||||
|
> `--@bitsquare:registry=<url>` for the same reason.
|
||||||
|
|
||||||
|
## Resolving from Gitea in this repo
|
||||||
|
|
||||||
|
This repository ships a root [`.npmrc`](.npmrc) that maps the scope:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
So any npm or pnpm command run from inside the repo resolves `@bitsquare/*` from
|
||||||
|
Gitea, with no flags — including a global install, since npm reads the project
|
||||||
|
`.npmrc` for those too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install -g @bitsquare/nopy@main # the newest snapshot, no flags needed
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not a trade against npmjs. Gitea is a strict **superset** of it for this
|
||||||
|
scope: `release.yml` publishes to both, `publish-snapshot.yml` pushes a `main`
|
||||||
|
snapshot to Gitea on every push, and today three of the four packages exist
|
||||||
|
*only* there. Pointing the scope at Gitea gains the snapshots and loses nothing.
|
||||||
|
|
||||||
|
`.npmrc` is otherwise gitignored — the publish workflows write credentials into
|
||||||
|
`.npmrc-gitea` / `.npmrc-release` — so `.gitignore` carries a `!/.npmrc`
|
||||||
|
negation for the root file specifically. **It contains the scope mapping and
|
||||||
|
nothing else.** Reads are anonymous; no token belongs in a committed file.
|
||||||
|
|
||||||
|
It cannot affect `pnpm install`: every `@bitsquare` dependency in the workspace
|
||||||
|
is a `workspace:*` range that resolves to a `link:`, so nothing in the tree is
|
||||||
|
ever fetched from that scope. Verified with `pnpm install --frozen-lockfile`.
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- **An untagged install resolves to nothing.** Gitea currently publishes no
|
||||||
|
`latest` dist-tag, so `npm i -g @bitsquare/nopy` finds no version — and npm
|
||||||
|
reports that by printing nothing and exiting 0. Always name a tag (`@main`,
|
||||||
|
`@next`) until the first `0.x` release lands. See
|
||||||
|
[Why 0.x and not 1.0.0-alphaN](#why-0x-and-not-100-alphan).
|
||||||
|
- **Bare lookups now answer for Gitea.** `npm view @bitsquare/nopy …` run from
|
||||||
|
the repo queries Gitea. Pass `--registry https://registry.npmjs.org/` when you
|
||||||
|
specifically mean npmjs.
|
||||||
|
|
||||||
|
To see both registries at once — which versions exist where, and which are on
|
||||||
|
Gitea only and therefore still testable and still un-published:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run registry:status
|
||||||
|
pnpm run registry:status -- --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Working **outside** the repo, set the same mapping globally once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm config set @bitsquare:registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
|
npm config delete @bitsquare:registry # back to npmjs
|
||||||
|
```
|
||||||
|
|
||||||
|
`nopy self-update` reads that key too (`npm config get @bitsquare:registry`), so
|
||||||
|
a CLI installed from Gitea keeps checking Gitea for its own updates with nothing
|
||||||
|
else configured.
|
||||||
|
|
||||||
|
### Why the publish jobs delete it
|
||||||
|
|
||||||
|
A scoped mapping is not just another way to say `--registry`. For a **scoped**
|
||||||
|
package npm resolves `@scope:registry` *before* `registry`, so the scoped key
|
||||||
|
wins no matter how the plain one was set — including on the command line. And a
|
||||||
|
project `.npmrc` outranks the userconfig the workflows write.
|
||||||
|
|
||||||
|
Left in place, that combination silently redirects the npmjs release lane:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ pnpm publish --tag latest --access public --registry https://registry.npmjs.org/ --dry-run
|
||||||
|
📦 @bitsquare/nopy@0.5.0 → https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Not hypothetical — that is the workflow's own command, measured. The
|
||||||
|
`npm view … --registry <npmjs>` idempotency guard inverts the same way: it
|
||||||
|
answers from Gitea, finds the version already there, and **skips the npmjs
|
||||||
|
publish entirely**. A release that reports success and shipped nothing.
|
||||||
|
|
||||||
|
Both publish workflows therefore `rm -f .npmrc` right after checkout, and every
|
||||||
|
publish and lookup names its registry as `--@bitsquare:registry=<url>`. Either
|
||||||
|
fix alone is sufficient — both are verified independently — and the pair means a
|
||||||
|
command added later cannot quietly inherit the wrong registry. Nothing else in
|
||||||
|
the job is affected: every `@bitsquare` range in the workspace is `workspace:*`,
|
||||||
|
so no install resolves through that scope.
|
||||||
|
|
||||||
|
## Testing a snapshot before you release
|
||||||
|
|
||||||
|
Every push to `main` publishes a snapshot, so the rehearsal for a release is to
|
||||||
|
install one the way a stranger would:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run try:snapshot # @main from Gitea
|
||||||
|
pnpm run try:snapshot -- --tag latest # a release, from Gitea
|
||||||
|
pnpm run try:snapshot -- --registry https://registry.npmjs.org/
|
||||||
|
pnpm run try:snapshot -- --keep # keep the directory
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/try-snapshot.mjs` builds a throwaway project in a temp directory,
|
||||||
|
points the `@bitsquare` scope at the registry, installs `@bitsquare/nopy` and
|
||||||
|
`@bitsquare/nopy-cubes-core` at that tag, and then:
|
||||||
|
|
||||||
|
- asserts the installed `nopy` declares a **concrete** `nopy-cubes` version
|
||||||
|
rather than a leaked `workspace:*` range;
|
||||||
|
- prints the three resolved versions, so you can see which commit you are on;
|
||||||
|
- runs `nopy --version`;
|
||||||
|
- runs `nopy install -P -D` with stdin closed and asserts the cube-selection
|
||||||
|
prompt listed cubes from the bundle — which only happens if the loader
|
||||||
|
resolved the package out of `node_modules` and imported every manifest.
|
||||||
|
|
||||||
|
It uses **npm**, not pnpm, on purpose: npm is the client that rejects a leaked
|
||||||
|
`workspace:` range, so a clean install here is the stronger proof. This is the
|
||||||
|
check `verify-pack.mjs` cannot be — that one inspects a local tarball, this one
|
||||||
|
goes to the real registry and runs the real binary.
|
||||||
|
|
||||||
|
The directory is deleted on success and left behind on failure, with its path
|
||||||
|
printed.
|
||||||
|
|
||||||
|
## Upgrading an installed CLI
|
||||||
|
|
||||||
|
Both CLIs can update themselves:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nopy self-update
|
||||||
|
keyman self-update
|
||||||
|
```
|
||||||
|
|
||||||
|
Each derives its channel from the version it is running — a `-main.` prerelease
|
||||||
|
came from the snapshot workflow, any other prerelease from `next`, a clean
|
||||||
|
version from `latest` — so an upgrade keeps you on the channel you installed
|
||||||
|
from instead of quietly moving you to another one. The registry comes from
|
||||||
|
`npm config get @bitsquare:registry`, so an install from Gitea checks Gitea
|
||||||
|
without any further configuration. The package manager is detected from the
|
||||||
|
install path (npm, pnpm, yarn or bun), so the update does not leave two copies
|
||||||
|
on the `PATH`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nopy self-update --dry-run # print the command, change nothing
|
||||||
|
nopy self-update --force # reinstall even when up to date
|
||||||
|
nopy self-update --channel next # switch channel
|
||||||
|
nopy self-update --registry <url> # check somewhere else
|
||||||
|
```
|
||||||
|
|
||||||
|
Once a day each CLI checks its channel at startup and prints a one-line hint to
|
||||||
|
**stderr** when something newer exists — never stdout, so `--json` and
|
||||||
|
`--print-only` stay machine-readable. Results are cached in
|
||||||
|
`~/.nopy/update-check.json` and `~/.keyman/update-check.json`; an unreachable
|
||||||
|
registry gets 1.5 seconds and is then ignored. The check is off whenever `CI` is
|
||||||
|
set, and `NOPY_NO_UPDATE_CHECK=1` / `KEYMAN_NO_UPDATE_CHECK=1` turn it off
|
||||||
|
explicitly. `NOPY_REGISTRY`, `NOPY_REGISTRY_TOKEN` and `NOPY_PACKAGE_MANAGER`
|
||||||
|
(and the `KEYMAN_` equivalents) override the three things it detects.
|
||||||
|
|
||||||
|
The logic lives in `packages/nopy/src/nopy.update.ts` and
|
||||||
|
`packages/keyman/src/keyman.update.ts` — two near-identical copies. keyman
|
||||||
|
shares no internal library with nopy by design, and a fifth workspace package
|
||||||
|
for ~250 lines would add another edge to the publish order for nothing. If a
|
||||||
|
third CLI appears, extract it then.
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
**Every publish is idempotent.** Each step asks the registry whether that exact
|
**Every publish is idempotent.** Each step asks the registry whether that exact
|
||||||
@@ -321,9 +593,17 @@ refuses to overwrite an existing version — without the check, a re-run would f
|
|||||||
on the first registry and never reach the second.
|
on the first registry and never reach the second.
|
||||||
|
|
||||||
**The build is explicit, publishes are `--ignore-scripts`.** `prepack` exists for
|
**The build is explicit, publishes are `--ignore-scripts`.** `prepack` exists for
|
||||||
humans running `npm pack` locally; in CI the build has already run as its own
|
humans packing locally; in CI the build has already run as its own step, and
|
||||||
step, and repeating it inside `npm publish` would only cost time and add a way
|
repeating it inside `pnpm publish` would only cost time and add a way for a
|
||||||
for a lifecycle script to change what ships after the gate looked at it.
|
lifecycle script to change what ships after the gate looked at it.
|
||||||
|
|
||||||
|
**`verify-pack.mjs` checks the artefact, not the source.** Reading `package.json`
|
||||||
|
in the repo would only tell you what you already know — every one of them says
|
||||||
|
`workspace:*`. The question is what pnpm wrote into the tarball, so the script
|
||||||
|
packs, extracts `package/package.json`, and reads the ranges back out. It cannot
|
||||||
|
pass `--ignore-scripts`, because `pnpm pack` has no such flag (only `pnpm publish`
|
||||||
|
does), so `prepack` rebuilds — which at least means the tarball under test is
|
||||||
|
byte-for-byte the one publish would ship.
|
||||||
|
|
||||||
**One job per workflow.** No artifact hand-off, no second install, no risk of
|
**One job per workflow.** No artifact hand-off, no second install, no risk of
|
||||||
publishing a tree that a different job built.
|
publishing a tree that a different job built.
|
||||||
@@ -352,7 +632,44 @@ See what would actually be in the tarball:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm run build
|
pnpm run build
|
||||||
cd packages/nopy && npm pack --dry-run --ignore-scripts
|
cd packages/nopy && pnpm pack --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that no `workspace:` range leaks into a published manifest — the same
|
||||||
|
check CI runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/verify-pack.mjs
|
||||||
|
node scripts/publish-order.mjs # the order to release in
|
||||||
|
node scripts/linked-deps.mjs packages/nopy # what must be on the registry first
|
||||||
|
```
|
||||||
|
|
||||||
|
See what is on each registry, and which versions Gitea has that npmjs does not:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run registry:status
|
||||||
|
```
|
||||||
|
|
||||||
|
Rehearse an install against a registry that has actually been published to —
|
||||||
|
see [Testing a snapshot](#testing-a-snapshot-before-you-release):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run try:snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Rehearse an install the way a stranger gets one, without publishing anything.
|
||||||
|
Use **npm**, not pnpm: npm is the one that rejects a leaked `workspace:` range,
|
||||||
|
so a clean install here is the real proof.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter @bitsquare/nopy-cubes pack --pack-destination /tmp/tgz
|
||||||
|
pnpm --filter @bitsquare/nopy pack --pack-destination /tmp/tgz
|
||||||
|
pnpm --filter @bitsquare/nopy-cubes-core pack --pack-destination /tmp/tgz
|
||||||
|
|
||||||
|
mkdir /tmp/try && cd /tmp/try && npm init -y
|
||||||
|
npm install /tmp/tgz/*.tgz
|
||||||
|
echo '{"hosts":["h"],"cubePackages":["@bitsquare/nopy-cubes-core"]}' > .nopyrc.json
|
||||||
|
./node_modules/.bin/nopy install -l session.json -P -D
|
||||||
```
|
```
|
||||||
|
|
||||||
Try the binary as an end user would get it, without publishing:
|
Try the binary as an end user would get it, without publishing:
|
||||||
@@ -360,17 +677,28 @@ Try the binary as an end user would get it, without publishing:
|
|||||||
```sh
|
```sh
|
||||||
cd packages/nopy && pnpm run link:local # build + npm link
|
cd packages/nopy && pnpm run link:local # build + npm link
|
||||||
nopy --help
|
nopy --help
|
||||||
npm unlink -g @bitstack/nopy
|
npm unlink -g @bitsquare/nopy
|
||||||
```
|
```
|
||||||
|
|
||||||
Check that a version is not already taken before you tag:
|
Check that a version is not already taken before you tag. The repo's `.npmrc`
|
||||||
|
points the scope at Gitea, so the bare lookup answers for Gitea and npmjs is the
|
||||||
|
one that needs the explicit flag:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm view @bitstack/nopy@1.2.0 version # npmjs
|
npm view @bitsquare/nopy@1.2.0 version # Gitea
|
||||||
npm view @bitstack/nopy@1.2.0 version \
|
npm view @bitsquare/nopy@1.2.0 version --registry https://registry.npmjs.org/ # npmjs
|
||||||
--registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or both registries, every package, in one table:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run registry:status
|
||||||
|
```
|
||||||
|
|
||||||
|
> `npm view <name>@<version>` exits 1 for a version that does not exist, so it is
|
||||||
|
> a sound check. `npm view <name>` — no version — is **not**: against a registry
|
||||||
|
> with no `latest` tag it prints nothing and exits 0.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Symptom | Cause and fix |
|
| Symptom | Cause and fix |
|
||||||
@@ -384,6 +712,9 @@ npm view @bitstack/nopy@1.2.0 version \
|
|||||||
| `npm pack --dry-run` step fails | A `files` or `bin` path no longer exists after the build. Fix before it reaches a registry. |
|
| `npm pack --dry-run` step fails | A `files` or `bin` path no longer exists after the build. Fix before it reaches a registry. |
|
||||||
| Snapshot workflow green, nothing installable | Snapshots are only on Gitea and only under `@main`. Point the scope at the Gitea registry. |
|
| Snapshot workflow green, nothing installable | Snapshots are only on Gitea and only under `@main`. Point the scope at the Gitea registry. |
|
||||||
| The release workflow did not trigger | The tag must match `*-v*` and must be pushed (`git push origin <tag>`), not just created. |
|
| The release workflow did not trigger | The tag must match `*-v*` and must be pushed (`git push origin <tag>`), not just created. |
|
||||||
|
| `EUNSUPPORTEDPROTOCOL` / `Unsupported URL Type "workspace:"` on install | A `workspace:` range reached a tarball — something published with `npm publish` instead of `pnpm publish`. `verify-pack.mjs` exists to catch this before it ships. |
|
||||||
|
| `... is not published yet on npmjs` before the gate runs | Releasing a package before its `nopy-cubes` dependency. Tag and release `nopy-cubes` first, then re-tag. |
|
||||||
|
| `verify-pack.mjs` fails locally with a build error | `pnpm pack` runs `prepack`, so a broken build fails the check. Fix the build; there is no skip flag. |
|
||||||
|
|
||||||
## Recovering from a bad publish
|
## Recovering from a bad publish
|
||||||
|
|
||||||
@@ -391,14 +722,14 @@ npm view @bitstack/nopy@1.2.0 version \
|
|||||||
Meanwhile:
|
Meanwhile:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm dist-tag add @bitstack/nopy@1.1.9 latest # point users back
|
npm dist-tag add @bitsquare/nopy@1.1.9 latest # point users back
|
||||||
npm deprecate @bitstack/nopy@1.2.0 "Broken build, use 1.2.1"
|
npm deprecate @bitsquare/nopy@1.2.0 "Broken build, use 1.2.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm unpublish` is only possible within 72 hours and burns the version number
|
`npm unpublish` is only possible within 72 hours and burns the version number
|
||||||
forever; a deprecation with a working `latest` is almost always the better move.
|
forever; a deprecation with a working `latest` is almost always the better move.
|
||||||
|
|
||||||
**On Gitea**, delete the version under **Packages → @bitstack/… → Settings**
|
**On Gitea**, delete the version under **Packages → @bitsquare/… → Settings**
|
||||||
before that exact version can be published again.
|
before that exact version can be published again.
|
||||||
|
|
||||||
**A bad tag** can be moved, but only before the release workflow has published
|
**A bad tag** can be moved, but only before the release workflow has published
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ they deploy.
|
|||||||
|
|
||||||
| Path | Package | Binary | What it is |
|
| Path | Package | Binary | What it is |
|
||||||
| ----------------- | ------------------ | -------- | --------------------------------------------------- |
|
| ----------------- | ------------------ | -------- | --------------------------------------------------- |
|
||||||
| `packages/nopy` | `@bitstack/nopy` | `nopy` | interactive pyinfra script management and execution |
|
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution |
|
||||||
| `packages/keyman` | `@bitstack/keyman` | `keyman` | SSH key management with `age` encryption |
|
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management with `age` encryption |
|
||||||
| `cubes/` | — | — | the deployment units `nopy` runs |
|
| `cubes/` | — | — | the deployment units `nopy` runs |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install -g @bitstack/nopy @bitstack/keyman
|
npm install -g @bitsquare/nopy @bitsquare/keyman
|
||||||
```
|
```
|
||||||
|
|
||||||
See each package's README for usage, and
|
See each package's README for usage, and
|
||||||
@@ -37,7 +37,7 @@ pnpm install
|
|||||||
|
|
||||||
`typescript` is on the 7.x native compiler, so `tsc` *is* the fast one — there is
|
`typescript` is on the 7.x native compiler, so `tsc` *is* the fast one — there is
|
||||||
no separate `tsgo` binary to keep in sync. Each package also has a dev-run script
|
no separate `tsgo` binary to keep in sync. Each package also has a dev-run script
|
||||||
(`pnpm --filter @bitstack/nopy run nopy`) that executes the TypeScript sources
|
(`pnpm --filter @bitsquare/nopy run nopy`) that executes the TypeScript sources
|
||||||
directly through `tsx`.
|
directly through `tsx`.
|
||||||
|
|
||||||
## Git hooks
|
## Git hooks
|
||||||
|
|||||||
Vendored
+91
-3
@@ -1,6 +1,27 @@
|
|||||||
# -*- mode: ruby -*-
|
# -*- mode: ruby -*-
|
||||||
# vi: set ft=ruby :
|
# vi: set ft=ruby :
|
||||||
|
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
|
# A destroyed-and-recreated box generates fresh SSH host keys, so the entry in
|
||||||
|
# ~/.ssh/known_hosts for [127.0.0.1]:2222 goes stale and pyinfra aborts with
|
||||||
|
# "Host key ... does not match" — it reads the real known_hosts, because its
|
||||||
|
# @vagrant connector copies only HostName/Port/User/IdentityFile out of
|
||||||
|
# `vagrant ssh-config` and drops the StrictHostKeyChecking/UserKnownHostsFile
|
||||||
|
# lines vagrant emits. Nor would relaxing that help: paramiko rejects a
|
||||||
|
# *mismatched* key before any policy is consulted.
|
||||||
|
#
|
||||||
|
# So: keep one keypair on the host, install it into every incarnation of the
|
||||||
|
# VM, and pin the known_hosts entry to it after boot.
|
||||||
|
HOSTKEY_DIR = File.join(__dir__, '.vagrant-hostkeys')
|
||||||
|
HOSTKEY_PATH = File.join(HOSTKEY_DIR, 'ssh_host_ed25519_key')
|
||||||
|
|
||||||
|
unless File.exist?(HOSTKEY_PATH)
|
||||||
|
FileUtils.mkdir_p(HOSTKEY_DIR)
|
||||||
|
system('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'ansiblingsvm', '-f', HOSTKEY_PATH) \
|
||||||
|
or raise "Vagrantfile: ssh-keygen failed to create #{HOSTKEY_PATH}"
|
||||||
|
end
|
||||||
|
|
||||||
Vagrant.configure("2") do |config|
|
Vagrant.configure("2") do |config|
|
||||||
config.vm.provider "vmware_desktop" do |vmware|
|
config.vm.provider "vmware_desktop" do |vmware|
|
||||||
vmware.gui = false
|
vmware.gui = false
|
||||||
@@ -9,8 +30,8 @@ Vagrant.configure("2") do |config|
|
|||||||
config.vm.box = "bento/ubuntu-24.04" # Use Ubuntu 24.04 box
|
config.vm.box = "bento/ubuntu-24.04" # Use Ubuntu 24.04 box
|
||||||
config.ssh.insert_key = false
|
config.ssh.insert_key = false
|
||||||
config.vm.box_check_update = false
|
config.vm.box_check_update = false
|
||||||
config.vm.hostname = "ansiblingsvm"
|
config.vm.hostname = "ansiblingsvm"
|
||||||
config.vm.network "forwarded_port", guest: 3567, host: 3567, auto_correct: true
|
config.vm.network "forwarded_port", guest: 3567, host: 3567, auto_correct: true
|
||||||
config.vm.network "forwarded_port", guest: 80, host: 80, auto_correct: false
|
config.vm.network "forwarded_port", guest: 80, host: 80, auto_correct: false
|
||||||
config.vm.network "forwarded_port", guest: 443, host: 443, auto_correct: false
|
config.vm.network "forwarded_port", guest: 443, host: 443, auto_correct: false
|
||||||
# Configure SSH with public key authentication
|
# Configure SSH with public key authentication
|
||||||
@@ -19,4 +40,71 @@ Vagrant.configure("2") do |config|
|
|||||||
# echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan" >> ~/.ssh/authorized_keys
|
# echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan" >> ~/.ssh/authorized_keys
|
||||||
# chmod 600 ~/.ssh/authorized_keys
|
# chmod 600 ~/.ssh/authorized_keys
|
||||||
#SHELL
|
#SHELL
|
||||||
end
|
|
||||||
|
# Land the private key as the vagrant user first; the shell provisioner
|
||||||
|
# below is what moves it into /etc/ssh with root ownership and 0600.
|
||||||
|
config.vm.provision "hostkey-upload",
|
||||||
|
type: "file",
|
||||||
|
run: "always",
|
||||||
|
source: HOSTKEY_PATH,
|
||||||
|
destination: "/tmp/ssh_host_ed25519_key"
|
||||||
|
config.vm.provision "hostkey-upload-pub",
|
||||||
|
type: "file",
|
||||||
|
run: "always",
|
||||||
|
source: "#{HOSTKEY_PATH}.pub",
|
||||||
|
destination: "/tmp/ssh_host_ed25519_key.pub"
|
||||||
|
|
||||||
|
# Idempotent: only restarts sshd when the key actually changed, so a
|
||||||
|
# `vagrant up` on an untouched VM does not bounce the connection.
|
||||||
|
config.vm.provision "hostkey-install",
|
||||||
|
type: "shell",
|
||||||
|
run: "always",
|
||||||
|
inline: <<-SHELL
|
||||||
|
set -eu
|
||||||
|
if cmp -s /tmp/ssh_host_ed25519_key /etc/ssh/ssh_host_ed25519_key \\
|
||||||
|
&& [ -f /etc/ssh/sshd_config.d/99-pinned-hostkey.conf ]; then
|
||||||
|
rm -f /tmp/ssh_host_ed25519_key /tmp/ssh_host_ed25519_key.pub
|
||||||
|
echo "host key already pinned"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -o root -g root -m 600 /tmp/ssh_host_ed25519_key /etc/ssh/ssh_host_ed25519_key
|
||||||
|
install -o root -g root -m 644 /tmp/ssh_host_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub
|
||||||
|
rm -f /tmp/ssh_host_ed25519_key /tmp/ssh_host_ed25519_key.pub
|
||||||
|
|
||||||
|
# Offer *only* this key. Ubuntu's sshd_config Includes sshd_config.d/*
|
||||||
|
# before its own (commented-out) HostKey lines, and naming any HostKey
|
||||||
|
# replaces the built-in default set — so a regenerated RSA or ECDSA key
|
||||||
|
# can never become the identity a client pins.
|
||||||
|
mkdir -p /etc/ssh/sshd_config.d
|
||||||
|
echo "HostKey /etc/ssh/ssh_host_ed25519_key" > /etc/ssh/sshd_config.d/99-pinned-hostkey.conf
|
||||||
|
chmod 644 /etc/ssh/sshd_config.d/99-pinned-hostkey.conf
|
||||||
|
|
||||||
|
sshd -t
|
||||||
|
systemctl restart ssh 2>/dev/null || service ssh restart
|
||||||
|
echo "host key pinned"
|
||||||
|
SHELL
|
||||||
|
|
||||||
|
# Established sessions survive the sshd restart above, but the *next*
|
||||||
|
# connection sees the new key — so refresh known_hosts on the host from the
|
||||||
|
# public key we already hold, rather than blind-trusting a keyscan.
|
||||||
|
config.trigger.after [:up, :provision, :reload] do |trigger|
|
||||||
|
trigger.name = "pin known_hosts entry"
|
||||||
|
trigger.ruby do |_env, machine|
|
||||||
|
info = machine.ssh_info
|
||||||
|
next if info.nil?
|
||||||
|
|
||||||
|
entry = "[#{info[:host]}]:#{info[:port]} #{File.read("#{HOSTKEY_PATH}.pub").split[0, 2].join(' ')}"
|
||||||
|
known_hosts = File.expand_path('~/.ssh/known_hosts')
|
||||||
|
|
||||||
|
FileUtils.mkdir_p(File.dirname(known_hosts), mode: 0o700)
|
||||||
|
FileUtils.touch(known_hosts) unless File.exist?(known_hosts)
|
||||||
|
system('ssh-keygen', '-q', '-R', "[#{info[:host]}]:#{info[:port]}", '-f', known_hosts,
|
||||||
|
out: File::NULL, err: File::NULL)
|
||||||
|
FileUtils.rm_f("#{known_hosts}.old")
|
||||||
|
File.open(known_hosts, 'a') { |f| f.puts(entry) }
|
||||||
|
|
||||||
|
machine.ui.info("known_hosts pinned to #{entry.split[1, 2].first} for #{info[:host]}:#{info[:port]}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export default cubes.Manifest({
|
|
||||||
id: 'user:add',
|
|
||||||
name: 'Add a user with fish shell and tools',
|
|
||||||
dependencies: () => ['apt:essentials'],
|
|
||||||
schema: z.object({
|
|
||||||
USER: z
|
|
||||||
.string()
|
|
||||||
.describe('Username for the new user account')
|
|
||||||
.default(() => `user${cubes.uniqid(5)}`),
|
|
||||||
PASSWORD: z.string().describe('Password for the new user account').default(cubes.uniqid),
|
|
||||||
GROUPS: z
|
|
||||||
.string()
|
|
||||||
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")')
|
|
||||||
.default(''),
|
|
||||||
PUBKEY: z
|
|
||||||
.string()
|
|
||||||
.describe('SSH public key to authorize for the user')
|
|
||||||
.default(
|
|
||||||
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan'
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
+4
-1
@@ -13,7 +13,9 @@
|
|||||||
"test": "pnpm -r run test",
|
"test": "pnpm -r run test",
|
||||||
"test:coverage": "pnpm -r run test:coverage",
|
"test:coverage": "pnpm -r run test:coverage",
|
||||||
"coverage:summary": "node scripts/coverage-summary.mjs",
|
"coverage:summary": "node scripts/coverage-summary.mjs",
|
||||||
"typecheck": "tsc --build --noEmit",
|
"registry:status": "node scripts/registry-status.mjs",
|
||||||
|
"try:snapshot": "node scripts/try-snapshot.mjs",
|
||||||
|
"typecheck": "tsc --build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"lint:ci": "biome ci .",
|
"lint:ci": "biome ci .",
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.5",
|
"@biomejs/biome": "^2.5.5",
|
||||||
|
"@bitsquare/nopy-cubes-core": "workspace:*",
|
||||||
"@logtape/logtape": "^2.2.4",
|
"@logtape/logtape": "^2.2.4",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"simple-git-hooks": "^2.13.1",
|
"simple-git-hooks": "^2.13.1",
|
||||||
|
|||||||
+212
-109
@@ -1,104 +1,80 @@
|
|||||||
# Keyman - SSH Key Management with Age Encryption
|
# keyman — SSH key management with an age-encrypted vault
|
||||||
|
|
||||||
Keyman is a simple command line tool built around the `age` encryption tool. It allows you to manage SSH keys in public GitHub repositories securely by encrypting the private keys.
|
keyman keeps SSH private keys in a vault you can commit. Each key is encrypted
|
||||||
|
with [age](https://github.com/FiloSottile/age) to a single recipient — the vault's
|
||||||
|
identity file — which is the one thing that has to stay out of the repository.
|
||||||
|
|
||||||
## Features
|
It is an interactive menu rather than a set of subcommands: point it at a vault,
|
||||||
|
pick an operation, repeat until you quit.
|
||||||
|
|
||||||
- 🔐 Encrypt SSH private keys with age encryption
|
## Requirements
|
||||||
- 📁 Organized vault structure: `vault/keys/` for encrypted keys, `vault/tmp/` for decrypted keys
|
|
||||||
- ⚙️ Configurable via `.keymanrc.json` with sensible defaults
|
|
||||||
- 🔍 Interactive CLI for encrypting, decrypting, and listing keys
|
|
||||||
- 🔄 Support for key rotation
|
|
||||||
|
|
||||||
## Quick Start
|
`age`, `age-keygen` and `ssh-keygen` on `PATH`. keyman shells out to all three and
|
||||||
|
names the missing one instead of failing obscurely.
|
||||||
|
|
||||||
### 1. Generate Age Encryption Key
|
## Installing
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
# Create vault structure
|
npm install -g @bitsquare/keyman@main \
|
||||||
mkdir -p vault/keys vault/tmp
|
--@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||||
|
```
|
||||||
|
|
||||||
# Generate age encryption key (keep this secret!)
|
Point the **scope** at that registry rather than setting a bare `--registry`: it
|
||||||
|
serves `@bitsquare` packages only and does not proxy npmjs, so every other
|
||||||
|
dependency has to keep resolving from npmjs. Reading needs no token while the
|
||||||
|
repository is public, and the same line works with `pnpm`.
|
||||||
|
|
||||||
|
`@main` is a snapshot of the default branch, published on every push. Name a tag —
|
||||||
|
there is no `latest` on that registry yet, so an untagged install resolves to
|
||||||
|
nothing, and keyman has not been released to npmjs. `keyman self-update` keeps you
|
||||||
|
on whichever channel you installed from.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The vault identity. The only secret in the vault, and the only thing here you
|
||||||
|
# cannot regenerate — back it up somewhere that is not this repository.
|
||||||
|
mkdir -p vault
|
||||||
age-keygen -o vault/age.key
|
age-keygen -o vault/age.key
|
||||||
|
|
||||||
# Add to .gitignore
|
# Run keyman against it.
|
||||||
echo "vault/age.key" >> .gitignore
|
|
||||||
echo "vault/tmp/" >> .gitignore
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Generate SSH Keys
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate SSH key pair
|
|
||||||
ssh-keygen -t ed25519 -f vault/tmp/id_deploy -N "" -C "deploy@myapp.dev"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run Keyman
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run keyman interactively
|
|
||||||
VAULT_ROOT=./vault keyman
|
VAULT_ROOT=./vault keyman
|
||||||
|
|
||||||
# Or if you have .keymanrc.json configured, just run:
|
|
||||||
keyman
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
On startup keyman creates `keys/` and `tmp/` under the vault at `0700` and writes
|
||||||
|
a `.gitignore` beside them covering the identity and `tmp/`, so a fresh vault
|
||||||
|
cannot be committed by accident.
|
||||||
|
|
||||||
Keyman uses sensible defaults but can be customized via `.keymanrc.json`:
|
Then pick **🆕 Generate key**: it makes the key pair and encrypts it into the vault
|
||||||
|
in one step. `ssh-keygen` collects the passphrase itself — keyman never sees it, so
|
||||||
```json
|
it can never put it on a command line.
|
||||||
{
|
|
||||||
"vaultRoot": "./vault",
|
|
||||||
"keysDir": "keys",
|
|
||||||
"tmpDir": "tmp",
|
|
||||||
"ageKeyFile": "age.key"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Priority
|
|
||||||
|
|
||||||
1. **VAULT_ROOT** environment variable (highest priority)
|
|
||||||
2. **.keymanrc.json** file (searched from current directory upward)
|
|
||||||
3. **Default values** (lowest priority)
|
|
||||||
|
|
||||||
### Default Values
|
|
||||||
|
|
||||||
- `vaultRoot`: `"vault"`
|
|
||||||
- `keysDir`: `"keys"`
|
|
||||||
- `tmpDir`: `"tmp"`
|
|
||||||
- `ageKeyFile`: `"age.key"`
|
|
||||||
|
|
||||||
## Vault Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
project/
|
|
||||||
├── vault/
|
|
||||||
│ ├── age.key # Master encryption key (NEVER commit!)
|
|
||||||
│ ├── keys/ # Encrypted keys (safe to commit)
|
|
||||||
│ │ └── deploy/ # Each key has its own folder
|
|
||||||
│ │ ├── id_deploy.pub # Public key
|
|
||||||
│ │ └── id_deploy.age # Encrypted private key
|
|
||||||
│ └── tmp/ # Decrypted keys (NEVER commit!)
|
|
||||||
│ ├── id_deploy # Decrypted private key
|
|
||||||
│ └── id_deploy.pub # Public key
|
|
||||||
└── .keymanrc.json # Configuration (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
Keyman provides an interactive menu-driven interface with the following operations:
|
Every operation returns to the menu, so a session can run several.
|
||||||
|
|
||||||
- **📋 List keys** - Compact view showing all keys with checkbox indicators for their locations
|
- **📋 List keys** — every key it can see and where it is: encrypted in the vault,
|
||||||
- **🔒 Encrypt keys** - Encrypt SSH keys from `vault/tmp/` and store in `vault/keys/`
|
decrypted in `tmp/`, live in `~/.ssh`, or some combination.
|
||||||
- **🔓 Decrypt keys** - Decrypt keys from `vault/keys/` to `vault/tmp/` or `~/.ssh/`
|
- **📝 Copy public key** — the public half of a key in `~/.ssh` or `tmp/`, to the
|
||||||
- **❌ Quit** - Exit the program
|
clipboard via whichever of `pbcopy`, `clip`, `wl-copy`, `xclip` or `xsel` exists.
|
||||||
|
With none of them, it prints the key instead.
|
||||||
|
- **🆕 Generate key** — an `ed25519` or 4096-bit `rsa` pair into `tmp/`, encrypted
|
||||||
|
into the vault straight away.
|
||||||
|
- **🔒 Encrypt keys** — pick from the private keys in `~/.ssh` *and* `tmp/`; each
|
||||||
|
goes to `<keysDir>/<name>/` with its public half beside it. A key that has no
|
||||||
|
`.pub` file gets one derived with `ssh-keygen -y`. One key failing costs only
|
||||||
|
that key.
|
||||||
|
- **🔓 Decrypt keys** — pick from the vault and decrypt to `tmp/` or `~/.ssh`.
|
||||||
|
Never overwrites a file without asking first, and the plaintext key is `0600`
|
||||||
|
from the moment it exists.
|
||||||
|
- **🔄 Rotate key** — a replacement for a vault key, encrypted *alongside* the
|
||||||
|
original. See below.
|
||||||
|
- **🗑️ Retire key** — the other half of a rotation: delete a vault key and its
|
||||||
|
plaintext copies, after listing every path that goes.
|
||||||
|
- **🧹 Clear decrypted keys** — remove the plaintext keys from `tmp/`.
|
||||||
|
- **❌ Quit**
|
||||||
|
|
||||||
After completing any operation, keyman automatically returns to the main menu, allowing you to perform multiple operations in a single session without restarting the tool.
|
### Listing
|
||||||
|
|
||||||
### List Keys Output
|
|
||||||
|
|
||||||
The list command shows a compact, unified view of all SSH keys with their locations:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
🔑 SSH Keys:
|
🔑 SSH Keys:
|
||||||
@@ -117,36 +93,163 @@ The list command shows a compact, unified view of all SSH keys with their locati
|
|||||||
⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)
|
⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Features:**
|
`(.pub)` means a public key was found next to the private one, in either location.
|
||||||
- Public keys are indicated with `(.pub)` suffix instead of separate entries
|
The rows are sorted by name.
|
||||||
- Status emoji shows management state at a glance
|
|
||||||
- Checkboxes `[✓]` show presence in three locations:
|
|
||||||
- **[Vault]** - Encrypted in vault/keys/
|
|
||||||
- **[Tmp]** - Decrypted in vault/tmp/
|
|
||||||
- **[.ssh]** - Active in ~/.ssh/
|
|
||||||
- Alphabetically sorted for easy scanning
|
|
||||||
- New **🔓** status for keys decrypted to tmp but not yet in .ssh
|
|
||||||
|
|
||||||
## Example Usage
|
### Rotating a key
|
||||||
|
|
||||||
```bash
|
Rotation is deliberately two operations, because both keys have to exist at once:
|
||||||
# Using environment variable
|
|
||||||
VAULT_ROOT=../../vault keyman
|
|
||||||
|
|
||||||
# Using default configuration
|
1. **🔄 Rotate key**, and pick `prod`. keyman generates `id_prod-2` in `tmp/`,
|
||||||
keyman
|
encrypts it to `keys/prod-2/`, and prints both public keys. `prod` is untouched.
|
||||||
|
2. Add the `prod-2` public key wherever `prod` is authorized.
|
||||||
|
3. Check that you can log in with `tmp/id_prod-2`.
|
||||||
|
4. Remove the `prod` public key from those hosts.
|
||||||
|
5. **🗑️ Retire key**, and pick `prod`.
|
||||||
|
|
||||||
# Keyman will show:
|
The name has to change: the vault directory is derived from it, so a replacement
|
||||||
# 📁 Vault Root: /path/to/vault
|
also called `prod` *is* the `prod` entry. Rotating again continues the series
|
||||||
# 🔑 Keys Directory: /path/to/vault/keys
|
(`prod-2` → `prod-3`), and a version already taken — in the vault, in `tmp/` or in
|
||||||
# 📂 Temp Directory: /path/to/vault/tmp
|
`~/.ssh` — is skipped rather than overwritten.
|
||||||
# 🔐 Age Key: /path/to/vault/age.key
|
|
||||||
|
Doing it in one step instead is what this shape avoids: replace the key in the
|
||||||
|
vault and you have locked yourself out of the host you were rotating for, because
|
||||||
|
the replacement is not on it yet and the only copy of the key that is has gone.
|
||||||
|
Retiring warns when nothing in the vault supersedes the key, and then asks you to
|
||||||
|
type its name.
|
||||||
|
|
||||||
|
### The `id_` prefix
|
||||||
|
|
||||||
|
keyman manages keys named `id_*`; the vault directory for `id_prod` is `prod`.
|
||||||
|
A private key named anything else is not offered by any operation — but List, Copy
|
||||||
|
and Encrypt report the ones they found, with a count and the reason, so it is
|
||||||
|
never silently invisible. Rename it to `id_<name>` to bring it in.
|
||||||
|
|
||||||
|
## Command line
|
||||||
|
|
||||||
|
```
|
||||||
|
keyman — SSH key management and an age-encrypted key vault
|
||||||
|
|
||||||
|
Usage
|
||||||
|
keyman start the interactive menu
|
||||||
|
keyman self-update update keyman itself (alias: upgrade)
|
||||||
|
|
||||||
|
Flags
|
||||||
|
-h, --help print this help and exit
|
||||||
|
-V, --version print the version and exit
|
||||||
|
--print-config print the resolved paths and the config files
|
||||||
|
they came from, as JSON, and exit
|
||||||
|
--self-update same as the self-update subcommand
|
||||||
|
|
||||||
|
Flags for self-update
|
||||||
|
--channel <latest|next|main> channel to update from
|
||||||
|
(default: derived from the running version)
|
||||||
|
--registry <url> registry to query instead of the configured one
|
||||||
|
-n, --dry-run print the install command without running it
|
||||||
|
-f, --force reinstall even when already up to date
|
||||||
|
|
||||||
|
Environment
|
||||||
|
VAULT_ROOT overrides vaultRoot from .keymanrc.json
|
||||||
|
KEYMAN_REGISTRY registry for the update check and self-update
|
||||||
|
KEYMAN_REGISTRY_TOKEN bearer token for a private registry
|
||||||
|
KEYMAN_NO_UPDATE_CHECK set to 1 to skip the once-a-day update check
|
||||||
|
(also skipped whenever CI is set)
|
||||||
|
KEYMAN_PACKAGE_MANAGER npm | pnpm | yarn | bun for the install command
|
||||||
|
|
||||||
|
Configuration is read from .keymanrc.json, merged from the current directory
|
||||||
|
upwards and then from ~/.keymanrc.json.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
The update channel is derived from the version you are running — a `-main.` build
|
||||||
|
checks `main`, any other prerelease checks `next`, a clean version checks `latest`
|
||||||
|
— so an update cannot quietly move you to a different channel. The check runs at
|
||||||
|
most once a day and prints its hint to **stderr**, which keeps `--print-config`
|
||||||
|
machine-readable.
|
||||||
|
|
||||||
1. **Never commit** `vault/age.key` or `vault/tmp/` to version control
|
## Configuration
|
||||||
2. **Always backup** your `age.key` securely (password manager, encrypted USB)
|
|
||||||
3. **Commit** `vault/keys/` - encrypted keys are safe to share
|
`.keymanrc.json`, with every key optional:
|
||||||
4. **Use environment variables** for CI/CD: `VAULT_ROOT=/path/to/vault keyman`
|
|
||||||
5. **Keep .keymanrc.json** in your project root for team consistency
|
```json
|
||||||
|
{
|
||||||
|
"vaultRoot": "vault",
|
||||||
|
"keysDir": "keys",
|
||||||
|
"tmpDir": "tmp",
|
||||||
|
"ageKeyFile": "age.key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| key | default | meaning |
|
||||||
|
| ------------ | ---------- | ---------------------------------------------------- |
|
||||||
|
| `vaultRoot` | `vault` | the vault directory; everything else lives inside it |
|
||||||
|
| `keysDir` | `keys` | the encrypted keys — the part that is safe to commit |
|
||||||
|
| `tmpDir` | `tmp` | decrypted keys, in plaintext |
|
||||||
|
| `ageKeyFile` | `age.key` | the age identity the vault encrypts to |
|
||||||
|
|
||||||
|
The last three are resolved against `vaultRoot` unless they are absolute. A
|
||||||
|
relative `vaultRoot` **in a config file** is resolved against that file's
|
||||||
|
directory, so a repository config keeps meaning the same vault from any
|
||||||
|
subdirectory; the built-in default is resolved against the current directory.
|
||||||
|
|
||||||
|
Files are read from `~/.keymanrc.json` first, then from the filesystem root down
|
||||||
|
to the current directory, so the nearest file wins key by key. `VAULT_ROOT` in the
|
||||||
|
environment beats all of them. A file that is not valid JSON is skipped with a
|
||||||
|
warning rather than taken as fatal, and a key keyman does not know is reported
|
||||||
|
instead of silently dropped — `{"vaultroot": "…"}` used to be indistinguishable
|
||||||
|
from an empty file.
|
||||||
|
|
||||||
|
`keyman --print-config` answers what all of that resolved to, and which files it
|
||||||
|
came from:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ keyman --print-config
|
||||||
|
{"vaultRoot":"/srv/infra/vault","keysDir":"/srv/infra/vault/keys","tmpDir":"/srv/infra/vault/tmp","keyPath":"/srv/infra/vault/age.key","configFiles":["/srv/infra/.keymanrc.json"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vault layout
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── vault/
|
||||||
|
│ ├── .gitignore # written by keyman: the identity and tmp/, not keys/
|
||||||
|
│ ├── age.key # the vault identity (NEVER commit)
|
||||||
|
│ ├── keys/ # encrypted keys (safe to commit)
|
||||||
|
│ │ └── deploy/ # one directory per key, named without the id_ prefix
|
||||||
|
│ │ ├── id_deploy.age # the private key, encrypted to the vault recipient
|
||||||
|
│ │ └── id_deploy.pub # the public key
|
||||||
|
│ └── tmp/ # decrypted keys (NEVER commit)
|
||||||
|
│ ├── id_deploy
|
||||||
|
│ └── id_deploy.pub
|
||||||
|
└── .keymanrc.json # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
With a custom `keysDir` or `tmpDir`, those two names change and nothing else does.
|
||||||
|
|
||||||
|
## Practices this tool assumes
|
||||||
|
|
||||||
|
1. **Back up `age.key`** somewhere outside the repository. It is the only thing
|
||||||
|
that can decrypt the vault, and nothing in the vault can reconstruct it.
|
||||||
|
2. **Commit `keys/`.** Encrypted keys are the point; a vault nobody shares is a
|
||||||
|
directory.
|
||||||
|
3. **Do not commit the identity or `tmp/`.** keyman writes a `.gitignore` for
|
||||||
|
this, and never overwrites one you wrote yourself — check it if you brought
|
||||||
|
your own.
|
||||||
|
4. **Clear `tmp/` when you are done with it** (🧹), so plaintext keys do not
|
||||||
|
outlive the reason they were decrypted.
|
||||||
|
5. **Keep `.keymanrc.json` in the project root** so everyone resolves the same
|
||||||
|
vault, and use `VAULT_ROOT` for the exceptions.
|
||||||
|
|
||||||
|
## Upgrading from a version before 0.7.0
|
||||||
|
|
||||||
|
`keysDir` and `tmpDir` used to be honoured by some operations and ignored by
|
||||||
|
others, which left anyone with custom names holding a **split vault**: `generate`
|
||||||
|
and `list` used the configured directories while `encrypt` and `decrypt` used
|
||||||
|
`<vaultRoot>/keys` and `<vaultRoot>/tmp`. All of them agree now, so anything
|
||||||
|
written by the old `encrypt` needs moving once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mv <vaultRoot>/keys/* <vaultRoot>/<keysDir>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nobody on the default names is affected — for them the two halves were the same
|
||||||
|
directory all along.
|
||||||
|
|||||||
@@ -0,0 +1,822 @@
|
|||||||
|
# keyman audit
|
||||||
|
|
||||||
|
A review of `packages/keyman` for defects, unimplemented features, and drift
|
||||||
|
between the code and the documents that describe it.
|
||||||
|
|
||||||
|
Severity is about what it costs a user:
|
||||||
|
|
||||||
|
- **🔴 broken** — normal use produces a crash, data loss, or a silently wrong result.
|
||||||
|
- **🟠 misleading** — the code or a document states something that is not true.
|
||||||
|
- **🟡 gap** — something real that nothing mentions, or dead weight nobody uses.
|
||||||
|
|
||||||
|
Verified against `75983ab` with no uncommitted changes in the package. Line
|
||||||
|
numbers are from that state. Findings marked **verified** were reproduced by
|
||||||
|
running the code, not inferred from reading it; the reproduction is quoted.
|
||||||
|
|
||||||
|
Baseline: 162 tests pass, 98.9 % lines / 96.2 % branches. High coverage is
|
||||||
|
context for §1.2, not a defence of it.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**All 30 findings are closed except the second half of §1.8**, over the ten phases
|
||||||
|
of `PLAN.md`. Each one keeps its original text as the record, with what closed it
|
||||||
|
quoted underneath; the line numbers still point at `75983ab`, so they are history
|
||||||
|
rather than directions. The one deliberate omission is making keys not named `id_*`
|
||||||
|
*manageable* — they are now reported rather than silently skipped, and the rest is a
|
||||||
|
change to the on-disk layout that wanted sizing first.
|
||||||
|
|
||||||
|
Where the audit ends: 336 tests, 99.3 % statements / 95.7 % branches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [1. Defects](#1-defects)
|
||||||
|
- [2. Security](#2-security)
|
||||||
|
- [3. Unimplemented and dead](#3-unimplemented-and-dead)
|
||||||
|
- [4. Public API and packaging](#4-public-api-and-packaging)
|
||||||
|
- [5. Documentation drift](#5-documentation-drift)
|
||||||
|
- [6. Checked and accurate](#6-checked-and-accurate)
|
||||||
|
- [Suggested order of attack](#suggested-order-of-attack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Defects
|
||||||
|
|
||||||
|
### 1.1 ✅ `keysDir` and `tmpDir` are honoured by half the tool — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 5.** `main.ts` passes `paths.keysDir` and `paths.tmpDir` to
|
||||||
|
> `encrypt` and `decrypt`, neither of which joins `vaultRoot` itself any more, so all
|
||||||
|
> five operations agree on the configured directories. `tests/vault-layout.test.ts` is
|
||||||
|
> the regression test: non-default names in a real config file, the real loader, one
|
||||||
|
> encrypt, and a listing that has to show the key in the vault column.
|
||||||
|
|
||||||
|
`resolveConfigPaths()` (`keyman.config.ts:249-259`) resolves all four paths from
|
||||||
|
the config, and `keyman.main.ts:18-21` prints them. But dispatch is inconsistent
|
||||||
|
about what it hands each operation:
|
||||||
|
|
||||||
|
| Operation | receives | uses |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `listKeys` (`main.ts:67`) | `paths.keysDir` | the configured directory |
|
||||||
|
| `generateKey` (`main.ts:73`) | `paths.keysDir`, `paths.tmpDir` | the configured directories |
|
||||||
|
| `copyKey` (`main.ts:70`) | `paths.tmpDir` | the configured directory |
|
||||||
|
| `encryptKeys` (`main.ts:76`) | `paths.vaultRoot` | **hardcoded** `<vaultRoot>/keys` (`encrypt.ts:38`) |
|
||||||
|
| `decryptKeys` (`main.ts:84`) | `paths.vaultRoot` | **hardcoded** `<vaultRoot>/keys` (`decrypt.ts:7`) and `<vaultRoot>/tmp` (`decrypt.ts:39,43`) |
|
||||||
|
|
||||||
|
With the defaults the two halves agree, which is why this is invisible. Set
|
||||||
|
either sub-directory and the vault splits in two.
|
||||||
|
|
||||||
|
**Verified.** With `{"vaultRoot":"./v","keysDir":"encrypted","tmpDir":"plain"}`,
|
||||||
|
`keyman --print-config` reports:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"...":"...","keysDir":"…/v/encrypted","tmpDir":"…/v/plain","keyPath":"…/v/age.key"}
|
||||||
|
```
|
||||||
|
|
||||||
|
so `generate` writes to `v/encrypted/<name>/` and `list` scans `v/encrypted`,
|
||||||
|
while `encrypt` writes to `v/keys`, and `decrypt` reads `v/keys` and writes
|
||||||
|
`v/tmp` — never touching either configured directory. Concretely:
|
||||||
|
|
||||||
|
- encrypt a key, then list it → the list shows nothing in `[Vault]`.
|
||||||
|
- generate a key, then decrypt it → "⚠️ No encrypted keys found."
|
||||||
|
- decrypt to local, then encrypt → the key is not offered, because `encrypt`
|
||||||
|
reads the configured `tmpDir` while `decrypt` wrote to the hardcoded one.
|
||||||
|
|
||||||
|
No error at any point. The user has two vaults and one of them is invisible to
|
||||||
|
whichever operation they try next.
|
||||||
|
|
||||||
|
The current behaviour is locked in by tests: `main.test.ts:164-187` asserts
|
||||||
|
`vaultRoot` is what encrypt and decrypt receive ("encrypts keys into the vault
|
||||||
|
root"), and `encrypt.test.ts:95` / `decrypt.test.ts:53` assert the literal
|
||||||
|
`keys` segment. Fixing this means changing those assertions.
|
||||||
|
|
||||||
|
**Fix.** Pass `paths.keysDir` and `paths.tmpDir` into `encryptKeys` and
|
||||||
|
`decryptKeys` and delete the three `path.join(vaultDir, 'keys' | 'tmp')` calls.
|
||||||
|
Neither function has a use for `vaultRoot` once that is done, so the parameter
|
||||||
|
goes away rather than becoming a second source of truth.
|
||||||
|
|
||||||
|
See also §5.1 — `DOCS-AUDIT.md` currently lists this layout under *checked and
|
||||||
|
accurate*.
|
||||||
|
|
||||||
|
### 1.2 ✅ Encrypt and decrypt crash with a raw stack trace on a first run — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phases 1 and 2.** `keyman.cli.ts` is an error boundary — a
|
||||||
|
> `UsageError` prints one line, anything else prints its message and exits 1, and
|
||||||
|
> neither prints a stack. The two readdirs that threw are guarded (§1.5).
|
||||||
|
|
||||||
|
Three `readdirSync` calls have no `existsSync` guard:
|
||||||
|
|
||||||
|
- `encrypt.ts:13` — `~/.ssh`, which nothing creates.
|
||||||
|
- `encrypt.ts:16` — the tmp directory.
|
||||||
|
- `decrypt.ts:8` — `<vault>/keys`, which nothing creates either.
|
||||||
|
|
||||||
|
`keyman.main.ts:40-41` creates `vaultRoot` and `tmpDir`. It does **not** create
|
||||||
|
`keysDir`, so `decrypt` on a fresh vault throws instead of printing its
|
||||||
|
"⚠️ No encrypted keys found." message — the message is unreachable until the
|
||||||
|
directory exists for some other reason.
|
||||||
|
|
||||||
|
**Verified**, calling both functions directly against a vault laid out the way
|
||||||
|
`main.ts` lays it out:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- A: decryptKeys with no vault/keys directory ---
|
||||||
|
THREW: Error ENOENT ENOENT: no such file or directory, scandir '…/vault/keys'
|
||||||
|
--- B: encryptKeys with no ~/.ssh directory ---
|
||||||
|
THREW: Error ENOENT ENOENT: no such file or directory, scandir '…/home/.ssh'
|
||||||
|
```
|
||||||
|
|
||||||
|
What the user sees is worse than the exception, because of `keyman.cli.ts:82`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
keyman();
|
||||||
|
```
|
||||||
|
|
||||||
|
Not awaited, no `.catch`. Any rejection anywhere in the menu loop becomes an
|
||||||
|
unhandled rejection: Node prints the stack and exits non-zero, and the menu loop
|
||||||
|
— whose whole point (`README.md:97`) is that you can run several operations in
|
||||||
|
one session — is gone.
|
||||||
|
|
||||||
|
`copyKey` guards (`copy.ts:8`) and `listKeys` guards all three of its
|
||||||
|
directories (`list.ts:22,50,78`). Encrypt and decrypt are the outliers, not the
|
||||||
|
rule.
|
||||||
|
|
||||||
|
Worth noting where the coverage numbers sit: `keyman.encrypt.ts` and
|
||||||
|
`keyman.decrypt.ts` are both at **100 % lines, 100 % branches**. Every test
|
||||||
|
creates the directories in `beforeEach` (`encrypt.test.ts:46-47`,
|
||||||
|
`decrypt.test.ts:54-55`), so the missing guard is not a branch that went
|
||||||
|
uncovered — it is a branch that was never written. Line coverage measures lines
|
||||||
|
executed, not inputs considered.
|
||||||
|
|
||||||
|
**Half closed (Phase 1).** `keyman()` is now awaited inside a `catch`, so a
|
||||||
|
rejection is one line rather than an unhandled-rejection stack trace. The missing
|
||||||
|
`existsSync` guards — and with them the menu loop surviving a failed operation —
|
||||||
|
are Phase 2.
|
||||||
|
|
||||||
|
### 1.3 ✅ A missing `age.key` becomes `age -r null` — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 3.** The recipient is resolved once per session, before any
|
||||||
|
> operation that needs one. A null aborts *that operation* with
|
||||||
|
> `age-keygen -o <path>` as the remedy and returns to the menu, and is retried on the
|
||||||
|
> next attempt, so creating the identity mid-session works. `age -r null` is now
|
||||||
|
> unreachable.
|
||||||
|
|
||||||
|
`keyman.main.ts:73` and `:80` assert away a null:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
extractAgePublicKey(paths.keyPath)!
|
||||||
|
```
|
||||||
|
|
||||||
|
`extractAgePublicKey` returns `string | null` (`utils.ts:8-22`) and returns null
|
||||||
|
in three cases: the file is missing, it is unreadable, or it parses but has no
|
||||||
|
`# public key:` line. In all three it prints an error and returns — and the
|
||||||
|
non-null assertion carries that null straight into an `execa` argv.
|
||||||
|
|
||||||
|
**Verified**, both halves:
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ ERROR: Age key file not found at /nope/age.key
|
||||||
|
extractAgePublicKey(missing) = null
|
||||||
|
execa with null recipient THREW: ExecaError | Command failed with exit code 1: age -r null -o /tmp/x.age /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
execa stringifies the null, so the recipient becomes the literal `"null"`.
|
||||||
|
|
||||||
|
The two call sites fail differently, and the generate path fails worse:
|
||||||
|
|
||||||
|
- `generateKey` runs `ssh-keygen` **first** (`generate.ts:59`) and `age` second
|
||||||
|
(`generate.ts:68`). Its `try/catch` swallows the failure into "❌ Error
|
||||||
|
generating/encrypting key", but by then the private key is on disk in `tmpDir`
|
||||||
|
in plaintext, and the user has been told the operation failed. Nothing tells
|
||||||
|
them a key was left behind.
|
||||||
|
- `encryptKeys` has no `try/catch` at all, so it takes the §1.2 path: unhandled
|
||||||
|
rejection, stack trace, session over.
|
||||||
|
|
||||||
|
**Fix.** Resolve the recipient once, before dispatch, and treat null as a
|
||||||
|
recoverable condition: print what to run (`age-keygen -o <keyPath>`) and return
|
||||||
|
to the menu. The type already says this is possible; the `!` is the only thing
|
||||||
|
claiming otherwise.
|
||||||
|
|
||||||
|
### 1.4 ✅ Decrypting into `~/.ssh` silently overwrites an existing key — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 4.** Every collision is settled before anything is written: a
|
||||||
|
> confirmation per key defaulting to no, and a skip that says what it kept. The user
|
||||||
|
> is answering about files that still exist.
|
||||||
|
|
||||||
|
`decrypt.ts:47-49` writes the decrypted key and copies the public key with no
|
||||||
|
existence check, no confirmation, and no backup.
|
||||||
|
|
||||||
|
**Verified** that `age -o` does not refuse an existing file:
|
||||||
|
|
||||||
|
```
|
||||||
|
before: PRECIOUS EXISTING KEY
|
||||||
|
age -o exit=0 (overwrote)
|
||||||
|
after: secret
|
||||||
|
```
|
||||||
|
|
||||||
|
So selecting `prod` with the `SSH (~/.ssh)` destination replaces
|
||||||
|
`~/.ssh/id_prod` outright. If the vault copy is stale, or the folder name
|
||||||
|
happens to collide with an unrelated local key, the local key is gone — and this
|
||||||
|
is the one operation in the tool that writes outside the vault, into the
|
||||||
|
directory the user's actual SSH access depends on.
|
||||||
|
|
||||||
|
The `Local (vault/tmp)` destination has the same behaviour but a much lower cost,
|
||||||
|
since `vault/tmp` is scratch space by design.
|
||||||
|
|
||||||
|
**Fix.** Check both output paths before decrypting anything and prompt per
|
||||||
|
collision, or refuse and name the file. A `--force` equivalent can come later;
|
||||||
|
the current default should not be "overwrite".
|
||||||
|
|
||||||
|
### 1.5 ✅ `age` or `ssh-keygen` missing is unhandled in encrypt and decrypt — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 2.** `runTool` turns `ENOENT` into a `ToolNotFoundError`
|
||||||
|
> whose message is an instruction, and keeps it distinct from a tool that ran and
|
||||||
|
> refused — whose reason is on stderr and nowhere in execa's message. `encrypt`
|
||||||
|
> re-throws it instead of counting it against one key.
|
||||||
|
|
||||||
|
Same missing `try/catch` as §1.3. `generateKey` (`generate.ts:51-76`) and
|
||||||
|
`copyKey` (`copy.ts:43-57`) both wrap their `execa` calls and report a failure;
|
||||||
|
`encryptKeys` and `decryptKeys` do not. On a machine without `age` on `PATH` —
|
||||||
|
the one hard external requirement, per `CLAUDE.md` — choosing Encrypt from the
|
||||||
|
menu produces an `ENOENT` stack trace rather than "install age".
|
||||||
|
|
||||||
|
### 1.6 ✅ Encrypt copies `.pub` unconditionally and aborts the batch midway — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 6.** `storeInVault` reads the `.pub` *before* the vault
|
||||||
|
> directory exists and derives a missing one with `ssh-keygen -y` — stdout piped,
|
||||||
|
> stdin and stderr inherited, because the passphrase prompt goes to stderr — storing
|
||||||
|
> the private key alone if it cannot. And a failing key costs one key: `encrypt`
|
||||||
|
> collects the failures and names them at the end.
|
||||||
|
|
||||||
|
`encrypt.ts:45`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
||||||
|
```
|
||||||
|
|
||||||
|
The selection list is built from *private* keys only (`encrypt.ts:14,17` filter
|
||||||
|
out `.pub`), so a private key with no `.pub` sibling is offered — and that is a
|
||||||
|
legal state, since `ssh-keygen -y` regenerates a public key on demand and people
|
||||||
|
do delete them.
|
||||||
|
|
||||||
|
When it happens, `age` has already written the `.age` file, so the throw leaves
|
||||||
|
the vault holding an encrypted key with no public key. Worse, the throw escapes
|
||||||
|
the `for` loop: every remaining selected key is skipped, with no output saying
|
||||||
|
so, and the process dies via §1.2.
|
||||||
|
|
||||||
|
`generate.ts:71` has the same shape but is far less likely to fire, since
|
||||||
|
`ssh-keygen` just wrote the file.
|
||||||
|
|
||||||
|
**Fix.** Derive the public key with `ssh-keygen -y -f <key>` when the sibling is
|
||||||
|
absent, and wrap the loop body so one bad key costs one key rather than the
|
||||||
|
batch.
|
||||||
|
|
||||||
|
### 1.7 ✅ `/home/<user>` is hardcoded — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 8.** `keyman.home.ts` resolves a named user against the
|
||||||
|
> sibling of the current home first, then `/home/<user>` and `/Users/<user>`, and
|
||||||
|
> reports every path it tried. An unset `HOME` falls back to the passwd entry instead
|
||||||
|
> of resolving `.ssh` against the filesystem root.
|
||||||
|
|
||||||
|
`keyman.main.ts:33`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS other users live under `/Users/`, and this tool is otherwise
|
||||||
|
macOS-specific (§1.9). Nothing checks the directory exists, so a wrong guess
|
||||||
|
feeds a nonexistent `sshDir` into §1.2 rather than into an error message.
|
||||||
|
`main.test.ts:198-204` locks in `/home/deploy/.ssh`.
|
||||||
|
|
||||||
|
**Fix.** `os.userInfo()` for the current user, and for a named user either look
|
||||||
|
the home directory up (`getent passwd` / `dscl`) or ask for the path outright.
|
||||||
|
Failing that, check `existsSync` and say so.
|
||||||
|
|
||||||
|
### 1.8 🟡 Keys not named `id_*` are invisible, silently — **half fixed**
|
||||||
|
|
||||||
|
> **Partly closed in Phase 8 — the rest is open.** `scanPrivateKeys` classifies a
|
||||||
|
> file by reading its first 64 bytes for a private-key header, and List, Copy and
|
||||||
|
> Encrypt report what they skipped, with the count, the directory and the reason. So
|
||||||
|
> the keys are no longer *silently* invisible.
|
||||||
|
>
|
||||||
|
> They are still not manageable. The vault layout derives `id_<dir>` from the
|
||||||
|
> directory name in four places, so accepting other names changes what is on disk;
|
||||||
|
> the plan asked for that to be sized before being committed to, and the report is
|
||||||
|
> the tenth of the work that closes most of the surprise. Left deliberately.
|
||||||
|
|
||||||
|
Every discovery filter requires the prefix: `copy.ts:9`, `encrypt.ts:14,17`,
|
||||||
|
`list.ts:23,51`, and `decrypt.ts:9` reconstructs `id_${dir}`. A key called
|
||||||
|
`deploy_ed25519` cannot be listed, copied, or encrypted, and nothing says why —
|
||||||
|
it simply is not in the menu.
|
||||||
|
|
||||||
|
`generateKey` enforces the prefix (`generate.ts:43`), so keys keyman creates are
|
||||||
|
always fine. The gap only bites pre-existing keys, which is exactly the
|
||||||
|
population a key manager is adopted to take over.
|
||||||
|
|
||||||
|
### 1.9 ✅ `pbcopy` is hardcoded — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 8.** `keyman.clipboard.ts` picks by platform — `pbcopy`,
|
||||||
|
> `clip`, or `wl-copy` → `xclip` → `xsel` — falls through only on `ENOENT` (a tool
|
||||||
|
> that ran and refused is a real error, not an absent tool), and prints the key when
|
||||||
|
> nothing is installed, since printing it was always the point.
|
||||||
|
|
||||||
|
`copy.ts:49`, with the comment above it admitting the shortcut:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux or Windows, Copy public key always fails. It fails *cleanly* — the
|
||||||
|
`try/catch` reports "❌ Failed to copy to clipboard" — but the package declares
|
||||||
|
only `"node": ">=22"` in `engines` and the README says nothing, so nothing warns
|
||||||
|
before install. `xclip`/`wl-copy`/`clip.exe` by platform is a handful of lines;
|
||||||
|
alternatively print the key to stdout as a fallback so the operation is never a
|
||||||
|
dead end.
|
||||||
|
|
||||||
|
### 1.10 ✅ Smaller things — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phases 2, 6 and 8.** All four: the `statSync` takes
|
||||||
|
> `throwIfNoEntry: false` and still follows a symlink to a real directory; both
|
||||||
|
> `replace` calls are anchored to `/^id_/`; a failed `age` now removes the file it
|
||||||
|
> named and the directory while it is empty, so nothing half-made is left claiming to
|
||||||
|
> hold a key; and both `console.log` debug lines are gone.
|
||||||
|
|
||||||
|
- **`listKeys` throws on a broken symlink.** `list.ts:80` calls `fs.statSync` on
|
||||||
|
every entry in the keys directory; a dangling symlink throws `ENOENT`, and
|
||||||
|
`listKeys` has no `try/catch`, so it exits via §1.2. `lstatSync`, or a
|
||||||
|
`withFileTypes` readdir, or a guard.
|
||||||
|
- **`key.replace('id_', '')` is unanchored** (`encrypt.ts:38`, `generate.ts:63`).
|
||||||
|
Every input is prefix-filtered today, so the first match *is* the prefix and
|
||||||
|
the behaviour is correct — it is a trap left for whoever loosens §1.8.
|
||||||
|
`replace(/^id_/, '')` costs nothing.
|
||||||
|
- **An `age` failure leaves an empty vault directory.** `generate.ts:65` creates
|
||||||
|
`<keysDir>/<name>/` before `generate.ts:68` runs `age`.
|
||||||
|
`generate.test.ts:150-163` asserts the `.pub` is absent afterwards but not the
|
||||||
|
directory, so this passes today. It makes the folder show up in
|
||||||
|
`decrypt`'s scan as a candidate that filters back out — harmless, untidy.
|
||||||
|
- **Debug output still in shipped code.** `encrypt.ts:18-19`
|
||||||
|
(`console.log(tmpKeys); console.log(sshKeys);`) is already tracked as
|
||||||
|
`DOCS-AUDIT.md` §6.4. `decrypt.ts:10` — a `console.log(keyfile)` *inside a
|
||||||
|
`filter` callback*, printing one line per vault directory — is not, and is the
|
||||||
|
more visible of the two.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Security
|
||||||
|
|
||||||
|
### 2.1 ✅ Decrypted private keys are world-readable before the chmod — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 4.** `fs.chmodSync(…, 0o600)` in-process, immediately after
|
||||||
|
> `age` returns — no `cp` or `chmod` spawn, so there is no window and no failure mode
|
||||||
|
> that leaves the mode behind. Confirmed again while probing Phase 10: age still
|
||||||
|
> writes 0644, and `ssh-keygen -y` refuses such a file outright.
|
||||||
|
|
||||||
|
`decrypt.ts:47-50` decrypts, then copies, then chmods — in three separate
|
||||||
|
processes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
||||||
|
await execa('cp', [publicKey, publicKeyOut]);
|
||||||
|
await execa('chmod', ['600', privateKeyOut]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verified** what `age` creates, and what `mkdirSync` at `main.ts:41` creates:
|
||||||
|
|
||||||
|
```
|
||||||
|
-rw-r--r-- …/out ← the decrypted private key, as age leaves it
|
||||||
|
drwxr-xr-x …/tmpdir ← vault/tmp, as keyman creates it
|
||||||
|
```
|
||||||
|
|
||||||
|
So a plaintext private key exists at `0644` for the lifetime of two process
|
||||||
|
spawns, inside a `0755` directory any local user can traverse. If the `chmod`
|
||||||
|
fails or the process is killed in between, it stays `0644` — and because
|
||||||
|
`decryptKeys` has no `try/catch` (§1.5), a failing `chmod` also kills the
|
||||||
|
session before the next key is even attempted.
|
||||||
|
|
||||||
|
**Fix.** `fs.chmodSync` immediately after `age` returns rather than a third
|
||||||
|
spawn; create `tmpDir` with `{mode: 0o700}` and `~/.ssh` likewise if it is
|
||||||
|
missing. Replacing `cp` and `chmod` with `fs.copyFileSync` / `fs.chmodSync` also
|
||||||
|
removes two shell-outs that do not work on Windows and cuts three spawns per key
|
||||||
|
to one.
|
||||||
|
|
||||||
|
### 2.2 ✅ The passphrase is passed on the `ssh-keygen` command line — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 6.** The prompt is gone and so is `-N`: `ssh-keygen` collects
|
||||||
|
> and confirms the passphrase itself with stdio inherited. A passphrase keyman never
|
||||||
|
> learns cannot leak from keyman — and a test asserts it never asks for one.
|
||||||
|
|
||||||
|
`generate.ts:53`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
|
||||||
|
```
|
||||||
|
|
||||||
|
argv is world-readable on both Linux (`/proc/<pid>/cmdline`) and macOS
|
||||||
|
(`ps -o command`) for the lifetime of the process. Any other user on the machine
|
||||||
|
can read the passphrase of a key being generated. `generate.test.ts:78-87`
|
||||||
|
asserts this exact argv.
|
||||||
|
|
||||||
|
**Fix.** Omit `-N` entirely and let `ssh-keygen` prompt on the tty — it already
|
||||||
|
asks twice and confirms, so keyman's own password prompt (`generate.ts:26-33`)
|
||||||
|
can go away rather than being replaced. That keeps the passphrase off argv
|
||||||
|
without keyman ever holding it.
|
||||||
|
|
||||||
|
### 2.3 ✅ The age recipient is trusted from a comment, never verified — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 3.** `age-keygen -y` derives the recipient from the secret
|
||||||
|
> key, so it cannot disagree with it. The comment survives only as a fallback for a
|
||||||
|
> machine with no `age-keygen`, behind a warning that it is unverified — and
|
||||||
|
> deliberately *not* as a fallback for `age-keygen` refusing the file, which means
|
||||||
|
> age cannot read the identity at all.
|
||||||
|
|
||||||
|
`extractAgePublicKey` (`utils.ts:16`) regexes the recipient out of a comment
|
||||||
|
line in the identity file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
fileContents.match(/^# public key:\s*(age1[^\s]+)/m)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing checks it corresponds to the private key in that same file. Edit the
|
||||||
|
comment — or concatenate two key files — and every subsequent encryption goes to
|
||||||
|
a recipient the local identity cannot decrypt. The failure surfaces only later,
|
||||||
|
at decrypt time, on keys that may no longer exist in plaintext anywhere.
|
||||||
|
|
||||||
|
**Fix.** `age-keygen -y <keyPath>` derives the public key *from the private key*
|
||||||
|
and is exactly the tool for this. Note that `age-keygen` is currently not
|
||||||
|
invoked anywhere in the source, despite `CLAUDE.md` listing it among the
|
||||||
|
binaries keyman shells out to (§5.5).
|
||||||
|
|
||||||
|
### 2.4 ✅ Nothing manages the plaintext left in `vault/tmp` — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 8.** A **🧹 Clear decrypted keys** operation that lists what it
|
||||||
|
> will delete and asks before deleting it, plus a `.gitignore` written beside the
|
||||||
|
> vault covering the identity and the tmp directory — never overwriting one that is
|
||||||
|
> already there, and never claiming to cover a path outside the vault.
|
||||||
|
|
||||||
|
Decrypted keys accumulate in `vault/tmp` indefinitely. There is no shred
|
||||||
|
operation, no warning on exit, and keyman never writes the `.gitignore` its own
|
||||||
|
README (`README.md:25-26`, `:148`) tells the user to write by hand. The only
|
||||||
|
signal is the 🔓 marker in `listKeys`, which the user has to go looking for.
|
||||||
|
|
||||||
|
A "Clear decrypted keys" menu entry and a `.gitignore` written alongside the
|
||||||
|
vault on first run would cost little and close the most likely way a private key
|
||||||
|
reaches a public repository — which is the threat this tool exists to address.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Unimplemented and dead
|
||||||
|
|
||||||
|
### 3.1 ✅ There is no `--help` — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 1.** `helpText()` in `keyman.args.ts`, checked against the
|
||||||
|
> parser's own flag table by a test so a new flag cannot ship undocumented, and now
|
||||||
|
> quoted verbatim in the README by a second test (§5.3).
|
||||||
|
|
||||||
|
`keyman.cli.ts` handles `--print-config`, `--version`/`-V`, and
|
||||||
|
`self-update`/`upgrade`, then falls through to the interactive session. `--help`
|
||||||
|
is not among them, and neither is any unknown-flag handling.
|
||||||
|
|
||||||
|
**Verified.** `keyman --help` with no tty:
|
||||||
|
|
||||||
|
```
|
||||||
|
📁 Vault Root: …
|
||||||
|
? Specify USER (default: @current): (@current)
|
||||||
|
…/@inquirer/core/dist/lib/create-prompt.js:67
|
||||||
|
reject(new ExitPromptError(`User force closed the prompt with ${code} ${signal}`));
|
||||||
|
```
|
||||||
|
|
||||||
|
Two problems in one output. `--help` starts a session instead of describing the
|
||||||
|
tool, and because of §1.2 the resulting `ExitPromptError` is an unhandled
|
||||||
|
rejection with a stack trace. That second half is what a user gets from **Ctrl-C
|
||||||
|
at any prompt** — the normal way to leave an interactive CLI produces a crash
|
||||||
|
dump.
|
||||||
|
|
||||||
|
`keyman --vault foo` is likewise accepted and ignored.
|
||||||
|
|
||||||
|
**Fix.** `--help` listing the flags, the two subcommands, and the `KEYMAN_*`
|
||||||
|
environment variables (§5.3); an unknown-flag error; and a `catch` in
|
||||||
|
`keyman.cli.ts` that treats `ExitPromptError` as "goodbye" and anything else as
|
||||||
|
a one-line error. nopy uses Commander for this; keyman need not, but it does
|
||||||
|
need the behaviour.
|
||||||
|
|
||||||
|
**Closed (Phase 1).** `src/keyman.args.ts` owns the parse and the help text; the
|
||||||
|
`catch` around `keyman()` in `keyman.cli.ts` turns `ExitPromptError` into
|
||||||
|
"👋 Goodbye!" and exit 0, and anything else into one line and exit 1.
|
||||||
|
|
||||||
|
### 3.2 ✅ `flagValue` accepts things that are not values — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 1.** `parseArgs` rejects a value flag with no value, a boolean
|
||||||
|
> flag given one, an unknown flag, an unknown command, an unknown channel, and a
|
||||||
|
> self-update-only flag used without `self-update`. `--channel --force` is now a
|
||||||
|
> usage error rather than a request for a dist-tag that cannot exist.
|
||||||
|
|
||||||
|
`keyman.cli.ts:24-27` is `args.indexOf(name)` and `args[index + 1]`:
|
||||||
|
|
||||||
|
- `--channel=main` is not recognised.
|
||||||
|
- `--channel` as the last argument yields `undefined`.
|
||||||
|
- `keyman self-update --channel --force` sets the channel to `"--force"`, which
|
||||||
|
is cast to `Channel` (`cli.ts:47`) and flows into the dist-tag lookup at
|
||||||
|
`update.ts:176` as a key that cannot exist. The registry answers, the tag is
|
||||||
|
absent, and the user is told "Could not reach <registry>" — which is false.
|
||||||
|
|
||||||
|
Validating against the three legal channels would turn all three into one clear
|
||||||
|
error.
|
||||||
|
|
||||||
|
**Closed (Phase 1).** `parseArgs` accepts both `--flag value` and `--flag=value`,
|
||||||
|
rejects a flag swallowed as another flag's value, and validates `--channel`
|
||||||
|
against `CHANNELS`.
|
||||||
|
|
||||||
|
### 3.3 ✅ The `resolution` merge machinery has no effect — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 7 — deleted.** Every keyman property is a string, so a child
|
||||||
|
> simply wins; `mergeConfigs` is one spread with a comment recording why nopy needs
|
||||||
|
> more and keyman does not. `keyman.config.ts` lost ~45 lines.
|
||||||
|
|
||||||
|
`keyman.config.ts` carries `ResolutionStrategy`, `KeymanResolutionConfig`,
|
||||||
|
`mergeValue` and `mergeConfigs` — roughly 45 lines, imported from nopy's design.
|
||||||
|
Every property in `KeymanConfigSchema` is a `z.string()`. For two strings,
|
||||||
|
`mergeValue` returns `childValue` in the `override` branch (`:121-123`) and
|
||||||
|
returns `childValue` again from the primitive fallthrough (`:156`). The two
|
||||||
|
strategies are indistinguishable for every key the schema permits, and the
|
||||||
|
array-concat and deep-merge branches are unreachable through a valid config —
|
||||||
|
unknown keys pass through the merge but are then stripped by
|
||||||
|
`KeymanConfigSchema.parse` (§3.5).
|
||||||
|
|
||||||
|
So the documented knob does nothing. The doc comment at `:186-194` advertises it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "vaultRoot": "../vault", "resolution": { "vaultRoot": "override" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
and `config.test.ts:212` — "honours an explicit override strategy" — passes for
|
||||||
|
a case where plain merge gives the same answer, so the test does not distinguish
|
||||||
|
them either.
|
||||||
|
|
||||||
|
This is a choice to make, not a bug to fix. Either drop the machinery and the
|
||||||
|
comment, or keep it deliberately as the shape a future object-valued or
|
||||||
|
array-valued option would need — and say so in a comment, since right now it
|
||||||
|
reads as functional.
|
||||||
|
|
||||||
|
### 3.4 ✅ `getConfigPaths()` is exported, tested, and called by nothing — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 7.** `describeConfig()` calls it, so `--print-config` prints
|
||||||
|
> `configFiles` — the files that were merged, in merge order. That was the one
|
||||||
|
> question the flag could not answer, and it existed only as unstructured stderr.
|
||||||
|
|
||||||
|
`keyman.config.ts:265` is used only by `config.test.ts:122,131`. It is not
|
||||||
|
re-exported from `src/index.ts` and not called by the CLI. nopy's equivalent
|
||||||
|
feeds `nopy.main.ts:64`.
|
||||||
|
|
||||||
|
The absence is felt: `--print-config` prints the *resolved paths* only, so there
|
||||||
|
is no way to ask which config files were consulted. That information exists only
|
||||||
|
as a stderr side effect of `loadConfig` ("✅ Loaded configuration from …"), which
|
||||||
|
is not machine-readable and is interleaved with warnings. Folding
|
||||||
|
`getConfigPaths()` into the `--print-config` JSON makes the function earn its
|
||||||
|
keep and makes the escape hatch answer the question it is for.
|
||||||
|
|
||||||
|
### 3.5 ✅ A typo in `.keymanrc.json` is silent — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 7.** `warnUnknownKeys` names the file, the keys it ignored and
|
||||||
|
> the keys it knows. Warned rather than fatal, which is this module's posture
|
||||||
|
> throughout, and warned per file because that is the only place the filename is in
|
||||||
|
> hand.
|
||||||
|
|
||||||
|
`KeymanConfigSchema` is a plain `z.object`, which strips unknown keys.
|
||||||
|
|
||||||
|
**Verified.** With `{"vaultRoot":"./v","vaultroot":"typo", …}`, the lowercase key
|
||||||
|
is dropped without a word and `--print-config` reports the vault from the
|
||||||
|
correct key. Had only the typo been present, the user would get the `vault`
|
||||||
|
default and no clue.
|
||||||
|
|
||||||
|
`.strict()` — or keeping the strip and logging the leftover keys as a warning —
|
||||||
|
turns a silently wrong vault into one line of output. Since `loadConfig` already
|
||||||
|
degrades to defaults rather than throwing, a warning fits the module's existing
|
||||||
|
posture better than a hard failure.
|
||||||
|
|
||||||
|
### 3.6 ✅ "Support for key rotation" does not exist — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 10 — built.** `keyman.rotate.ts`: **🔄 Rotate key** generates a
|
||||||
|
> replacement under the next name in the series and encrypts it *alongside* the
|
||||||
|
> original, and **🗑️ Retire key** deletes the superseded key after listing every path
|
||||||
|
> that goes, asking for the name to be typed out when nothing in the vault supersedes
|
||||||
|
> it. Two operations rather than one, because a rotation that replaces the key in
|
||||||
|
> place locks you out of the host you were rotating for.
|
||||||
|
|
||||||
|
`README.md:11`. `grep -rn "rotat" packages/keyman/src/` returns nothing. Already
|
||||||
|
tracked as `DOCS-AUDIT.md` §2.10, still open. Rotation is a genuinely useful
|
||||||
|
operation for this tool — generate a replacement, encrypt it, keep the old one
|
||||||
|
until the new one is deployed — so this is worth building rather than deleting.
|
||||||
|
|
||||||
|
### 3.7 ✅ "Copy public key and create README" — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 5.** The comment went with the rewrite of `encrypt`. No
|
||||||
|
> per-key README was ever written and nothing claims one now; the `README.md` fixture
|
||||||
|
> in `decrypt.test.ts` is a stray-file case, which `listVaultKeys` ignores.
|
||||||
|
|
||||||
|
`encrypt.ts:44` says it; no README is written. Suggestively,
|
||||||
|
`decrypt.test.ts:75` places a `README.md` inside the keys directory as a
|
||||||
|
fixture, so a per-key README appears to have been the intent once. Either build
|
||||||
|
it or drop the half of the comment that lies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Public API and packaging
|
||||||
|
|
||||||
|
### 4.1 ✅ A shebang on the library entry point — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9.** The shebang is gone, with a comment saying why the file
|
||||||
|
> does not want one. Verified against the built `dist/index.js`.
|
||||||
|
|
||||||
|
`src/index.ts:1` is `#!/usr/bin/env node`. The bin is `dist/keyman.cli.js`
|
||||||
|
(`package.json:28`); `index.ts` is the `exports["."]` target and is only ever
|
||||||
|
imported. nopy's `src/index.ts` has no shebang. Harmless, and a copy-paste
|
||||||
|
artefact.
|
||||||
|
|
||||||
|
### 4.2 ✅ The exported functions' types are not exported — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9.** `KeymanConfig` and `KeymanConfigFile` are exported;
|
||||||
|
> `ResolutionStrategy` and `KeymanResolutionConfig` no longer exist (§3.3).
|
||||||
|
|
||||||
|
`src/index.ts:2` exports `loadConfig` and `resolveConfigPaths`. It does not
|
||||||
|
export `KeymanConfig`, `KeymanConfigFile`, `ResolutionStrategy` or
|
||||||
|
`KeymanResolutionConfig`, so a TypeScript consumer cannot name what `loadConfig`
|
||||||
|
returns or what `resolveConfigPaths` takes. This is the same one-line omission
|
||||||
|
`CLAUDE.md` already records for nopy's `CubePackageRef`.
|
||||||
|
|
||||||
|
### 4.3 ✅ `export * from './keyman.main.js'` exports only `keyman()` — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9 — decided, and written down.** The surface is deliberately
|
||||||
|
> narrow: config resolution, the update machinery, and `keyman()`. The operation
|
||||||
|
> modules stay internal because every one of them prompts, prints and spawns, so
|
||||||
|
> there is nothing to do with a single one except rebuild the menu around it. The
|
||||||
|
> rule is now a comment at the top of `src/index.ts` rather than an accident.
|
||||||
|
|
||||||
|
The five operation modules and `extractAgePublicKey` are not on the public
|
||||||
|
surface, so the package is consumable as a library only as "run the entire
|
||||||
|
interactive menu". That may well be intended — but then `loadConfig` and
|
||||||
|
`resolveConfigPaths` being exported is the odd part, since a consumer can obtain
|
||||||
|
the paths and do nothing with them.
|
||||||
|
|
||||||
|
### 4.4 ✅ Update-module constants are half re-exported — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9.** `export * from './keyman.update.js'`, so the rule is
|
||||||
|
> "all of it" and the list cannot drift again. Verified by importing the built
|
||||||
|
> `dist/index.js` and reading its keys.
|
||||||
|
|
||||||
|
`keyman.update.ts` exports `SCOPE`, `UPDATE_CACHE_DIR`, `UPDATE_CACHE_FILE`,
|
||||||
|
`DEFAULT_FETCH_TIMEOUT_MS` and `DEFAULT_CONFIG_TIMEOUT_MS`; `src/index.ts:12-31`
|
||||||
|
re-exports neither, while re-exporting `DEFAULT_CHECK_INTERVAL_MS` and
|
||||||
|
`NPMJS_REGISTRY`. Pick one rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Documentation drift
|
||||||
|
|
||||||
|
### 5.1 ✅ `DOCS-AUDIT.md` lists §1.1 under *checked and accurate* — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 5.** The claim `DOCS-AUDIT.md` makes — that the documented
|
||||||
|
> vault layout matches the code — is now *true*, which is the substance of it; §1.1 is
|
||||||
|
> what made it false. The entry has been amended to say what it actually checked.
|
||||||
|
|
||||||
|
`DOCS-AUDIT.md:826-827`:
|
||||||
|
|
||||||
|
> **keyman config** — priority (`VAULT_ROOT` > file > defaults), the four default
|
||||||
|
> values, and the vault layout match `keyman.config.ts` and `keyman.encrypt.ts`.
|
||||||
|
|
||||||
|
The first two clauses are correct. The third holds only because
|
||||||
|
`keyman.encrypt.ts` hardcodes `keys` — checking the documented layout against
|
||||||
|
the file that ignores the config is what made §1.1 invisible. The entry should
|
||||||
|
move out of section 7 and point at §1.1.
|
||||||
|
|
||||||
|
### 5.2 ✅ `README.md` operations list — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9.** All nine menu entries are documented, and a test asserts
|
||||||
|
> the README contains every label `keyman.main.ts` offers, so a tenth cannot arrive
|
||||||
|
> undocumented. Encrypt is described as it behaves: the union of `~/.ssh` and the tmp
|
||||||
|
> directory.
|
||||||
|
|
||||||
|
`DOCS-AUDIT.md` §2.10, re-verified: `README.md:90-96` lists four menu entries;
|
||||||
|
`main.ts:54-61` has six. `Copy public key` and `Generate key` are undocumented —
|
||||||
|
the latter being the only in-tool way to create a key, which is why the Quick
|
||||||
|
Start at `README.md:33` tells the user to run `ssh-keygen` by hand.
|
||||||
|
`README.md:93` says encrypt takes keys "from `vault/tmp/`"; `encrypt.ts:12-20`
|
||||||
|
unions `~/.ssh` and tmp and offers both.
|
||||||
|
|
||||||
|
### 5.3 ✅ The README documents none of the CLI surface — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9.** The README carries `helpText()` verbatim — every flag,
|
||||||
|
> both subcommand spellings, and all five environment variables — with a test that
|
||||||
|
> fails if the two diverge. Installation, the update channels and the once-a-day
|
||||||
|
> check are documented too.
|
||||||
|
|
||||||
|
`README.md` covers the interactive menu and the config file. It does not mention:
|
||||||
|
|
||||||
|
- `self-update` / `upgrade`, `--dry-run`, `--force`, `--channel`, `--registry`
|
||||||
|
- `--version` / `-V`, `--print-config`
|
||||||
|
- `KEYMAN_REGISTRY`, `KEYMAN_REGISTRY_TOKEN`, `KEYMAN_NO_UPDATE_CHECK`,
|
||||||
|
`KEYMAN_PACKAGE_MANAGER`
|
||||||
|
- the once-a-day update check, or that it is disabled when `CI` is set
|
||||||
|
|
||||||
|
`README.PUBLISH.md:552-578` documents all of it, but `package.json:37-41` ships
|
||||||
|
only `dist`, `README.md` and `LICENSE` — so a reader on the registry sees none of
|
||||||
|
it. This is the same shape as the nopy README problem closed as
|
||||||
|
`DOCS-AUDIT.md` §2.9, and keyman is now the worse of the two.
|
||||||
|
|
||||||
|
### 5.4 ✅ The README presents a configurable layout that is half-real — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phase 9.** The section documents what §1.1 made true: the three
|
||||||
|
> inner names resolve against `vaultRoot`, a relative `vaultRoot` in a config file
|
||||||
|
> resolves against that file's directory, and the built-in default resolves against
|
||||||
|
> the current directory. It ends with the migration note for a vault written by the
|
||||||
|
> old `encrypt`.
|
||||||
|
|
||||||
|
`README.md:46-70` documents `keysDir` and `tmpDir` as configuration, and
|
||||||
|
`:72-86` draws the default tree. Per §1.1 the first is only half true. Whichever
|
||||||
|
way §1.1 is resolved, this section needs an edit.
|
||||||
|
|
||||||
|
### 5.5 ✅ `CLAUDE.md` names a binary keyman never runs — **fixed**
|
||||||
|
|
||||||
|
> **Closed in Phases 3 and 9.** `age-keygen` became true in Phase 3 (`-y`, to
|
||||||
|
> derive the recipient), and `cp`/`chmod` stopped being spawned in Phase 4.
|
||||||
|
> `CLAUDE.md` now says all of that, records the deliberate `resolution` divergence
|
||||||
|
> from nopy, and lists the operations the menu actually has.
|
||||||
|
|
||||||
|
> Encryption shells out to `age` / `age-keygen` / `ssh-keygen`, which must be on
|
||||||
|
> `PATH`.
|
||||||
|
|
||||||
|
`age-keygen` appears nowhere in `packages/keyman/src`. It appears in
|
||||||
|
`README.md:22` as a manual setup step, which is presumably where the claim came
|
||||||
|
from. Either note it as a prerequisite the user runs rather than something
|
||||||
|
keyman invokes, or make §2.3 true and turn the claim into fact.
|
||||||
|
|
||||||
|
`CLAUDE.md` also does not mention that `decryptKeys` shells out to `cp` and
|
||||||
|
`chmod` (`decrypt.ts:49-50`) — see §2.1, where the recommendation is to stop.
|
||||||
|
|
||||||
|
### 5.6 ✅ The update module has not drifted from nopy's
|
||||||
|
|
||||||
|
`keyman.update.ts` and `nopy.update.ts` are described in `CLAUDE.md` as "two
|
||||||
|
near-identical copies of one module", the duplication deliberate. Diffed with
|
||||||
|
package names normalised: **every difference is a doc comment.** No behavioural
|
||||||
|
drift at all. The stated risk of the duplication has not materialised; nopy's
|
||||||
|
copy simply carries fuller comments, and porting the better ones over would cost
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Checked and accurate
|
||||||
|
|
||||||
|
- **Config precedence.** `VAULT_ROOT` > config file > defaults
|
||||||
|
(`config.ts:249-259`), matching `README.md:59-70`. Verified via
|
||||||
|
`--print-config`.
|
||||||
|
- **Upward traversal and the home-directory config.** `findConfigFiles`
|
||||||
|
(`config.ts:85-110`) collects root-first and de-duplicates the home config
|
||||||
|
when it is also an ancestor (`:105`).
|
||||||
|
- **`loadConfig` never throws.** Invalid JSON is skipped per file (`:220-226`)
|
||||||
|
and a failed final validation degrades to defaults (`:232-241`) — which is the
|
||||||
|
documented difference from nopy's behaviour, and it holds.
|
||||||
|
- **`extractAgePublicKey` is honest about failure.** It returns `null` in every
|
||||||
|
failure mode and prints why; the defect in §1.3 is entirely in the caller's
|
||||||
|
`!`.
|
||||||
|
- **The menu loop.** Returns to the menu after every operation
|
||||||
|
(`main.ts:45-91`), as `README.md:97` says.
|
||||||
|
- **The four default values** and the `id_<name>.age` / `id_<name>.pub` layout
|
||||||
|
inside a per-key folder, as drawn at `README.md:72-86`.
|
||||||
|
- **`listKeys` status logic** (`list.ts:127-128`) matches its legend and the
|
||||||
|
README's, including the 🔓 state.
|
||||||
|
- **The update module**, in full — see §5.6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order of attack
|
||||||
|
|
||||||
|
> Superseded by `PLAN.md`, which turned this into ten phases and is the record of
|
||||||
|
> what was actually done in what order. Kept because the reasoning about which
|
||||||
|
> findings share a shape is still the reason the phases group the way they do.
|
||||||
|
|
||||||
|
**1 — the crashes, together.** §1.2, §1.3, §1.5 and §1.10's `statSync` are all
|
||||||
|
the same shape: an unguarded call in a function with no error boundary, reaching
|
||||||
|
a `keyman()` that is never awaited. One `catch` in `keyman.cli.ts` that
|
||||||
|
distinguishes `ExitPromptError` from a real failure, plus `existsSync` guards and
|
||||||
|
`try/catch` in encrypt and decrypt, closes all of them and most of §3.1's second
|
||||||
|
half. This is the smallest change with the largest effect on what a first run
|
||||||
|
feels like.
|
||||||
|
|
||||||
|
**2 — §1.4 and §2.1.** Both are in `decryptKeys`, both are about writing outside
|
||||||
|
the vault, and one of them destroys data. Replacing `cp`/`chmod` with the `fs`
|
||||||
|
equivalents is part of the same edit.
|
||||||
|
|
||||||
|
**3 — §1.1.** Mechanical, but it changes four test assertions, so it wants to be
|
||||||
|
its own commit. Fix `DOCS-AUDIT.md` §5.1 in the same one.
|
||||||
|
|
||||||
|
**4 — decide on §3.3 and §3.6.** Both are features the documentation claims and
|
||||||
|
the code does not have; both are decisions rather than fixes. Rotation is worth
|
||||||
|
building. The `resolution` machinery probably is not, and deleting it would take
|
||||||
|
`keyman.config.ts` from 267 lines to around 220.
|
||||||
|
|
||||||
|
**5 — §5.2, §5.3 and §5.4** are one rewrite of `README.md`. It is the only
|
||||||
|
document that ships, and it currently describes two thirds of the menu and none
|
||||||
|
of the command line.
|
||||||
|
|
||||||
|
**6 — the rest.** §2.2 (drop the passphrase prompt, let `ssh-keygen` ask), §2.3
|
||||||
|
(`age-keygen -y`), §2.4 (a shred operation), §1.6 through §1.9, §3.2, §3.4,
|
||||||
|
§3.5, and the §4 one-liners.
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
# keyman remediation plan
|
||||||
|
|
||||||
|
Turns [`AUDIT.md`](./AUDIT.md) into sequenced work. Each phase is one commit,
|
||||||
|
independently landable, gate-green on its own. Section references (§) are to
|
||||||
|
`AUDIT.md`.
|
||||||
|
|
||||||
|
Ordering is by *blast radius per unit of risk*, not by severity: the error
|
||||||
|
boundary comes first because it makes every later phase's failure mode legible,
|
||||||
|
and the config threading comes late because it is the only phase that rewrites
|
||||||
|
existing test assertions.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**All ten phases have landed**, one commit each, on the `keyman-remediation`
|
||||||
|
branch. Three deviations worth knowing about:
|
||||||
|
|
||||||
|
- **Phase 6's literal instruction was impossible.** "Move the `mkdirSync` after
|
||||||
|
`age` succeeds" cannot be done — `age -o` will not create its output directory.
|
||||||
|
The goal (no leftover directory) is met by cleaning up on failure instead, which
|
||||||
|
also removes a truncated `.age` the plan had not accounted for.
|
||||||
|
- **§1.8 is half done, deliberately**, exactly as the plan asked: the skipped-key
|
||||||
|
report is in, the layout change that would make non-`id_*` keys manageable is
|
||||||
|
not. See `AUDIT.md` §1.8.
|
||||||
|
- **Rollout has not been done.** No version bump, no tag, nothing published — the
|
||||||
|
cut points below are still proposals, and pushing this branch to `main` would
|
||||||
|
publish a snapshot, so that is the user's call to make.
|
||||||
|
|
||||||
|
Both open decisions were resolved the way the plan recommended: the `resolution`
|
||||||
|
machinery was deleted, and rotation was built.
|
||||||
|
|
||||||
|
## Verified before planning
|
||||||
|
|
||||||
|
Four things the fixes depend on, checked by running them rather than assumed —
|
||||||
|
two of them changed the prescription:
|
||||||
|
|
||||||
|
| Check | Result | Consequence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ssh-keygen` with `-N` omitted | Prompts `Enter passphrase … (empty for no passphrase)` **and** confirms | §2.2 fix works: omit `-N`, inherit stdio, keyman never holds the passphrase |
|
||||||
|
| `ssh-keygen -y -f <encrypted key>` | **Prompts for the passphrase** | §1.6 fix cannot be a silent spawn — needs `stdio: 'inherit'` and a skip path |
|
||||||
|
| `@inquirer/core` from keyman | `ERR_MODULE_NOT_FOUND` — transitive via `inquirer`, not a direct dep | Detect `ExitPromptError` by `error.name`, never by import |
|
||||||
|
| `z.strictObject` in zod 4.4.3 | Available; reports `unrecognized_keys` with a `keys` array | §3.5 has a hard-failure option, though the plan prefers a warning |
|
||||||
|
|
||||||
|
## What is not a breaking change
|
||||||
|
|
||||||
|
Per §4.3, `src/index.ts` exports only `keyman`, `loadConfig`,
|
||||||
|
`resolveConfigPaths` and the update module. `encryptKeys`, `decryptKeys`,
|
||||||
|
`generateKey`, `listKeys`, `copyKey` and `extractAgePublicKey` are **not** on the
|
||||||
|
public surface, so every signature change below is internal. Phases 2–6 are not
|
||||||
|
semver-breaking.
|
||||||
|
|
||||||
|
The one user-visible behaviour change is Phase 5 — see [Migration](#migration).
|
||||||
|
|
||||||
|
## Gate discipline
|
||||||
|
|
||||||
|
`lint:ci` → `typecheck` → `test:coverage` runs on `pre-push` and in CI. Two
|
||||||
|
standing constraints:
|
||||||
|
|
||||||
|
- **Every phase lands its tests with its fix.** No phase may leave a red gate,
|
||||||
|
so there is no "write the failing tests first" commit.
|
||||||
|
- **`keyman.cli.ts` is excluded from coverage** (`vitest.config.ts:18`). Per
|
||||||
|
`CLAUDE.md`, *adding logic to those files means moving it somewhere covered* —
|
||||||
|
which is why Phase 1 extracts argument parsing into a new module rather than
|
||||||
|
growing `cli.ts`.
|
||||||
|
|
||||||
|
Per-phase verification is `pnpm --filter @bitsquare/keyman run test`; the full
|
||||||
|
gate (`pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage`) before
|
||||||
|
each push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Error boundary, `--help`, argument validation
|
||||||
|
|
||||||
|
Closes §3.1, §3.2, and the second half of §1.2 (the crash dump).
|
||||||
|
|
||||||
|
First because it is pure addition, touches no operation module, and converts
|
||||||
|
every latent throw in phases 2–6 from a stack dump into a line of text. The
|
||||||
|
`ExitPromptError` half is independently worth shipping: today **Ctrl-C at any
|
||||||
|
prompt** produces a crash dump.
|
||||||
|
|
||||||
|
**New file `src/keyman.args.ts`** (covered by the gate, unlike `cli.ts`):
|
||||||
|
|
||||||
|
- `parseArgs(argv: string[]): ParsedArgs` — supports `--flag value` *and*
|
||||||
|
`--flag=value`, rejects a flag consumed as another flag's value, rejects
|
||||||
|
unknown flags, and validates `--channel` against `'latest' | 'next' | 'main'`
|
||||||
|
so §3.2's false "Could not reach <registry>" cannot happen.
|
||||||
|
- `helpText(): string` — flags, both subcommands, and the four `KEYMAN_*`
|
||||||
|
variables. This is the text Phase 9 keeps in step with the README.
|
||||||
|
|
||||||
|
**`src/keyman.cli.ts`** stays wiring: dispatch on the parse result, and
|
||||||
|
|
||||||
|
```ts
|
||||||
|
try {
|
||||||
|
await keyman();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { name?: string }).name === 'ExitPromptError') {
|
||||||
|
console.log('\n👋 Goodbye!\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error(`❌ ${error instanceof Error ? error.message : error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`error.name`, not `instanceof` — `@inquirer/core` is not a direct dependency and
|
||||||
|
does not resolve from this package.
|
||||||
|
|
||||||
|
**Tests** — new `tests/args.test.ts`: each rejection, both flag forms, the
|
||||||
|
channel whitelist, and that `helpText()` names every flag `parseArgs` accepts
|
||||||
|
(so the two cannot drift).
|
||||||
|
|
||||||
|
**Done when** `keyman --help` prints usage and exits 0 without loading config or
|
||||||
|
prompting; `keyman --bogus` errors; Ctrl-C prints Goodbye and exits 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Guards and error handling in encrypt/decrypt
|
||||||
|
|
||||||
|
Closes §1.2 (first half), §1.5, and §1.10's `statSync`.
|
||||||
|
|
||||||
|
- `encrypt.ts:13,16` and `decrypt.ts:8` — `existsSync` guard, falling through to
|
||||||
|
the "⚠️ No …" message each function already has but cannot currently reach.
|
||||||
|
- `main.ts:40-41` — create `keysDir` alongside `vaultRoot` and `tmpDir`. Use
|
||||||
|
`{recursive: true, mode: 0o700}` now, so Phase 4 does not have to revisit it.
|
||||||
|
- Wrap the `age` spawns in both functions. An `ENOENT` on the binary gets its own
|
||||||
|
message ("`age` was not found on PATH") — it is the one hard external
|
||||||
|
requirement and currently the least legible failure.
|
||||||
|
- `list.ts:80` — `readdirSync(dir, {withFileTypes: true})` instead of
|
||||||
|
`statSync` per entry, which also drops N stat calls and fixes the broken-symlink
|
||||||
|
throw.
|
||||||
|
- Delete the debug logging while in these files: `encrypt.ts:18-19` and
|
||||||
|
`decrypt.ts:10` (§1.10). That also closes `DOCS-AUDIT.md` §6.4's open bullet.
|
||||||
|
|
||||||
|
**Tests** — the cases the current suite structurally cannot have, because every
|
||||||
|
`beforeEach` pre-creates the directories: encrypt with no `~/.ssh`, encrypt with
|
||||||
|
no tmp, decrypt with no `<vault>/keys`, each asserting the warning and no throw.
|
||||||
|
Plus `list` with a dangling symlink in the keys directory.
|
||||||
|
|
||||||
|
**Note on coverage.** `encrypt.ts` and `decrypt.ts` are at 100 % lines and
|
||||||
|
branches *today*. The number will not move; the tests are the point.
|
||||||
|
|
||||||
|
**Done when** a first run against an empty vault can reach every menu entry and
|
||||||
|
return to the menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Resolve the age recipient once, and derive it properly
|
||||||
|
|
||||||
|
Closes §1.3 and §2.3, and makes `CLAUDE.md`'s `age-keygen` claim true (§5.5).
|
||||||
|
|
||||||
|
Two changes that belong together because both are about the recipient:
|
||||||
|
|
||||||
|
1. **`utils.ts` — derive, don't scrape.** `extractAgePublicKey` currently regexes
|
||||||
|
`# public key:` out of a comment (`utils.ts:16`) and trusts it. Replace with
|
||||||
|
`age-keygen -y <keyPath>`, which derives the public key *from the private key*
|
||||||
|
and cannot disagree with it. Keep the comment parse as a fallback for when
|
||||||
|
`age-keygen` is absent, behind a warning that the recipient is unverified.
|
||||||
|
The function becomes `async`.
|
||||||
|
2. **`main.ts:73,80` — delete both `!`.** Resolve the recipient once before the
|
||||||
|
`switch`, and treat `null` as recoverable: print the remedy
|
||||||
|
(`age-keygen -o <keyPath>`) and `break` back to the menu. This is the whole of
|
||||||
|
§1.3 — the type already said null was possible.
|
||||||
|
|
||||||
|
Sequencing matters inside the phase: fix the call site first. Without it, a
|
||||||
|
missing key file still reaches `age -r null`, and the generate path still leaves
|
||||||
|
a **plaintext private key in `tmpDir`** after telling the user the operation
|
||||||
|
failed.
|
||||||
|
|
||||||
|
**Tests** — `utils.test.ts` gains the `age-keygen -y` path with `execa` mocked,
|
||||||
|
the fallback-with-warning path, and the both-unavailable path. `main.test.ts`
|
||||||
|
gains: missing recipient → neither `generateKey` nor `encryptKeys` is called, a
|
||||||
|
remedy is printed, and the menu loop continues.
|
||||||
|
|
||||||
|
**Done when** `keyman` against a vault with no `age.key` reaches the menu,
|
||||||
|
refuses generate and encrypt with a remedy, and still offers list and decrypt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Decrypt: stop overwriting, stop the 0644 window
|
||||||
|
|
||||||
|
Closes §1.4 and §2.1. The highest-value phase — §1.4 is the only finding that
|
||||||
|
destroys data the user did not ask to touch.
|
||||||
|
|
||||||
|
- **Collision check before any decryption.** Both output paths, both modes.
|
||||||
|
Prompt per collision, defaulting to skip; `~/.ssh` deserves the friction more
|
||||||
|
than `vault/tmp` does, but the check is the same code.
|
||||||
|
- **Replace the shell-outs** (`decrypt.ts:49-50`) with `fs.copyFileSync` and
|
||||||
|
`fs.chmodSync`. Three spawns per key become one, it works on Windows, and it
|
||||||
|
removes a `cp` that overwrites unconditionally.
|
||||||
|
- **Close the permission window.** Verified: `age -o` creates the file `0644`
|
||||||
|
and `mkdirSync` creates `vault/tmp` as `0755`, so a plaintext key is
|
||||||
|
world-readable for the duration of two process spawns — and stays `0644` if the
|
||||||
|
`chmod` fails. `fs.chmodSync` immediately after `age` resolves; `mode: 0o700`
|
||||||
|
on the directory (already done in Phase 2); create `~/.ssh` `0700` if absent.
|
||||||
|
|
||||||
|
**Test rework — the fiddliest in the plan.** `decrypt.test.ts` asserts on the
|
||||||
|
mocked spawns: `argsOf('cp')` (`:98,111`) and `argsOf('chmod')` (`:99,112`) both
|
||||||
|
disappear, and `execa` is mocked with a bare `mockResolvedValue` (`:57`) that
|
||||||
|
writes no output file. Once `copyFileSync` is real it needs a real file, so the
|
||||||
|
mock must write to its `-o` argument the way `encrypt.test.ts:51-54` already
|
||||||
|
does. Assert the on-disk result and mode instead of the argv — a better test
|
||||||
|
than the one it replaces, since it checks the outcome rather than the mechanism.
|
||||||
|
`execa` call counts also change (`:123`: six spawns → two).
|
||||||
|
|
||||||
|
**Done when** decrypting onto an existing key requires a confirmation, and the
|
||||||
|
decrypted key is never observable at anything but `0600`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Thread `keysDir` and `tmpDir` through encrypt and decrypt
|
||||||
|
|
||||||
|
Closes §1.1 and the `DOCS-AUDIT.md` entry in §5.1.
|
||||||
|
|
||||||
|
Mechanical, but it is the one phase that rewrites assertions that pass today, so
|
||||||
|
it stays its own commit with nothing else in it.
|
||||||
|
|
||||||
|
- `encryptKeys(sshDir, keysDir, tmpDir, pubkey)` — drop `vaultDir`, delete the
|
||||||
|
hardcoded `path.join(vaultDir, 'keys')` (`encrypt.ts:38`).
|
||||||
|
- `decryptKeys(sshDir, keysDir, tmpDir, ageKey)` — drop `vaultDir`, delete the
|
||||||
|
hardcoded joins at `decrypt.ts:7,39,43`.
|
||||||
|
- `main.ts:76,84` — pass `paths.keysDir` and `paths.tmpDir`. Neither function has
|
||||||
|
any remaining use for `vaultRoot`, so the parameter goes rather than becoming a
|
||||||
|
second source of truth.
|
||||||
|
|
||||||
|
**Assertions to change** — all four, named so the diff is reviewable:
|
||||||
|
|
||||||
|
| Location | Today | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `main.test.ts:164-175` | "encrypts keys into the vault root", asserts `paths.vaultRoot` | asserts `paths.keysDir`, `paths.tmpDir` |
|
||||||
|
| `main.test.ts:177-187` | asserts `paths.vaultRoot` | asserts `paths.keysDir`, `paths.tmpDir` |
|
||||||
|
| `encrypt.test.ts:95,115,128-129` | `path.join(vaultDir, 'keys', …)` | `path.join(keysDir, …)` |
|
||||||
|
| `decrypt.test.ts:53,89` | `keyDir = path.join(vaultDir, 'keys')` | `keysDir` passed in directly |
|
||||||
|
|
||||||
|
**New test, the one that would have caught this:** a config with
|
||||||
|
`keysDir: 'encrypted'` and `tmpDir: 'plain'`, encrypt a key, then list it, and
|
||||||
|
assert the listing shows it in `[Vault]`. That round trip fails today and is the
|
||||||
|
regression worth owning.
|
||||||
|
|
||||||
|
**Also in this commit:** move the `DOCS-AUDIT.md:826-827` bullet out of *checked
|
||||||
|
and accurate* and point it at this finding. It was verified against
|
||||||
|
`keyman.encrypt.ts` — the file that ignores the config — which is precisely how
|
||||||
|
§1.1 stayed invisible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Generate: passphrase off argv, and `.pub` recovery
|
||||||
|
|
||||||
|
Closes §2.2, §1.6, and §1.10's leftover-directory bullet.
|
||||||
|
|
||||||
|
**Passphrase (§2.2).** Verified: omitting `-N` makes `ssh-keygen` prompt *and*
|
||||||
|
confirm. So delete keyman's own password prompt (`generate.ts:26-33`), omit `-N`,
|
||||||
|
and spawn with `stdio: 'inherit'`. The passphrase never enters keyman's memory
|
||||||
|
and never reaches argv — strictly better than routing it more carefully, and it
|
||||||
|
deletes code. `generate.test.ts:78-87` loses `-N`/`'pw'` from the expected argv
|
||||||
|
and the password-prompt case goes away.
|
||||||
|
|
||||||
|
**Missing `.pub` (§1.6).** The selection list is built from private keys only, so
|
||||||
|
an orphan private key is offered and `copyFileSync` throws *after* `age` has
|
||||||
|
written the `.age` file — leaving a vault entry with no public key and killing
|
||||||
|
the rest of the batch. Fix in two parts:
|
||||||
|
|
||||||
|
- Derive it with `ssh-keygen -y -f <key>` when the sibling is absent. **Verified
|
||||||
|
that this prompts for a passphrase on an encrypted key**, so it needs
|
||||||
|
`stdio: 'inherit'` and a clean skip when the user cannot or will not supply it
|
||||||
|
— not a silent spawn whose stdout is captured.
|
||||||
|
- Wrap the loop body in `encrypt.ts:36-48` per key, so one bad key costs one key.
|
||||||
|
Report the failures at the end rather than dying at the first.
|
||||||
|
|
||||||
|
**Leftover directory.** `generate.ts:65` creates `<keysDir>/<name>/` before
|
||||||
|
`age` runs at `:68`. Move the `mkdirSync` after `age` succeeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Config: warn on typos, decide on the dead machinery
|
||||||
|
|
||||||
|
Closes §3.5, §3.4, and asks for a decision on §3.3.
|
||||||
|
|
||||||
|
**Typos (§3.5).** Verified: `{"vaultroot": "…"}` is silently stripped by
|
||||||
|
`z.object`. Warn per file rather than failing — diff `Object.keys(rawConfig)`
|
||||||
|
against the schema keys plus `resolution` inside the existing per-file loop
|
||||||
|
(`config.ts:210-227`), where the filename is in hand. That names the offending
|
||||||
|
file, which `z.strictObject` cannot do from the merged result, and it preserves
|
||||||
|
the module's documented posture of degrading to defaults rather than throwing.
|
||||||
|
(`z.strictObject` is available in zod 4.4.3 and reports `unrecognized_keys` with
|
||||||
|
a `keys` array, if a hard failure is preferred later.)
|
||||||
|
|
||||||
|
**`getConfigPaths` (§3.4).** Add it to the `--print-config` JSON as
|
||||||
|
`configFiles`. The function is currently exercised only by its own test, and
|
||||||
|
`--print-config` currently cannot answer *which files were read* — that exists
|
||||||
|
only as unstructured stderr from `loadConfig`. One change fixes both.
|
||||||
|
|
||||||
|
**Decision needed — the `resolution` machinery (§3.3).** Roughly 45 lines
|
||||||
|
(`config.ts:23-30,115-157`) that cannot affect a valid config, because every
|
||||||
|
schema property is a `string` and both strategies return `childValue` for
|
||||||
|
primitives. `config.test.ts:212` "honours an explicit override strategy" passes
|
||||||
|
either way.
|
||||||
|
|
||||||
|
- **Recommended: delete it**, along with the doc comment at `:186-194` that
|
||||||
|
advertises it. `keyman.config.ts` goes from 267 lines to roughly 220, and the
|
||||||
|
config file stops documenting a knob that does nothing.
|
||||||
|
- **Alternative: keep it** as the shape a future array- or object-valued option
|
||||||
|
would need — but then say so in a comment, because today it reads as
|
||||||
|
functional, and make `config.test.ts:212` assert something that distinguishes
|
||||||
|
the two strategies (which requires a non-string property to exist first).
|
||||||
|
|
||||||
|
Deleting is the smaller lie. It also diverges from nopy, where the machinery
|
||||||
|
*is* load-bearing — worth a line in `CLAUDE.md` so the divergence reads as
|
||||||
|
deliberate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Portability and the gaps that make keys invisible
|
||||||
|
|
||||||
|
Closes §1.7, §1.8, §1.9, §2.4. Independent of each other; split if any grows.
|
||||||
|
|
||||||
|
- **Clipboard (§1.9).** `pbcopy` / `wl-copy` / `xclip` / `clip.exe` by platform,
|
||||||
|
falling back to printing the key to stdout so the operation is never a dead
|
||||||
|
end. Delete the comment at `copy.ts:46-48` that admits the shortcut.
|
||||||
|
- **Home directory (§1.7).** `os.userInfo()` for the current user; for a named
|
||||||
|
user, look the home directory up rather than assuming `/home/<user>` — wrong on
|
||||||
|
the one platform the tool currently supports. Check `existsSync` and say so,
|
||||||
|
instead of feeding a nonexistent path into a `readdir`.
|
||||||
|
`main.test.ts:198-204` changes.
|
||||||
|
- **Non-`id_*` keys (§1.8).** Relax the filters (`copy.ts:9`, `encrypt.ts:14,17`,
|
||||||
|
`list.ts:23,51`) to *any* private key with a recognisable header, or at minimum
|
||||||
|
print a count of the keys that were skipped and why. Today a key named
|
||||||
|
`deploy_ed25519` is simply absent from the menu — and pre-existing keys are the
|
||||||
|
population a key manager is adopted to take over. `decrypt.ts:9` reconstructs
|
||||||
|
`id_${dir}` from the folder name, so the vault layout has the assumption baked
|
||||||
|
in; relaxing discovery means storing the real filename per key, which is the
|
||||||
|
largest single item in this plan. **Size it before committing to it** — a
|
||||||
|
skipped-key count is a tenth of the work and closes most of the surprise.
|
||||||
|
- **Plaintext hygiene (§2.4).** A "🧹 Clear decrypted keys" menu entry, and write
|
||||||
|
a `.gitignore` next to the vault on first run covering `age.key` and `tmp/` —
|
||||||
|
which `README.md:25-26` currently tells the user to do by hand. This is the
|
||||||
|
cheapest guard against the exact failure the tool exists to prevent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — Documentation
|
||||||
|
|
||||||
|
Closes §5.2, §5.3, §5.4, §5.5. Last, so it documents what the code now does.
|
||||||
|
|
||||||
|
- **`README.md` — the only shipped document** (`package.json:37-41` ships `dist`,
|
||||||
|
`README.md`, `LICENSE`). Currently describes four of six menu entries, invents
|
||||||
|
key rotation, and mentions none of `self-update`, `--print-config`,
|
||||||
|
`--version`, `--help`, or the four `KEYMAN_*` variables. `README.PUBLISH.md`
|
||||||
|
has all of it and never reaches a reader on the registry. Reuse Phase 1's
|
||||||
|
`helpText()` as the source for the CLI section so the two cannot drift.
|
||||||
|
- **`README.md:46-86`** — the configuration and vault-layout sections, now that
|
||||||
|
Phase 5 makes `keysDir`/`tmpDir` real, plus the migration note below.
|
||||||
|
- **`README.md:11`** — drop "Support for key rotation" unless Phase 10 lands
|
||||||
|
first.
|
||||||
|
- **`README.md:33`** — stop telling the user to shell out to `ssh-keygen`; the
|
||||||
|
Generate operation exists.
|
||||||
|
- **`CLAUDE.md`** — `age-keygen` becomes true in Phase 3; note that `cp`/`chmod`
|
||||||
|
are gone (Phase 4) and record the `resolution` divergence from nopy (Phase 7).
|
||||||
|
- **`AUDIT.md`** — mark findings closed, keeping their text as the record, the way
|
||||||
|
`DOCS-AUDIT.md` does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — Key rotation (decision required)
|
||||||
|
|
||||||
|
§3.6. `README.md:11` has advertised it since before this audit;
|
||||||
|
`grep -rn "rotat" packages/keyman/src/` returns nothing.
|
||||||
|
|
||||||
|
Unlike the rest of this plan it is a feature, not a repair, and it is the one
|
||||||
|
item that could reasonably be dropped instead. **Recommendation: build it** —
|
||||||
|
rotation is the operation that makes a key vault worth having, and the pieces all
|
||||||
|
exist by Phase 6 (generate under a new name, encrypt, keep the old key until the
|
||||||
|
replacement is deployed, then shred). Sketch:
|
||||||
|
|
||||||
|
1. Pick an existing vault key.
|
||||||
|
2. Generate a replacement into `tmpDir` under a versioned name.
|
||||||
|
3. Encrypt it alongside the current one — never replacing it.
|
||||||
|
4. Report both public keys, so the new one can be deployed before the old one
|
||||||
|
goes.
|
||||||
|
5. A separate "retire" step that removes the superseded key once the user
|
||||||
|
confirms.
|
||||||
|
|
||||||
|
Steps 3 and 4 are the whole value: a rotation that atomically replaces the key is
|
||||||
|
a rotation that locks you out of the host you were rotating for. If this is
|
||||||
|
deferred, delete the README claim in Phase 9 instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Phase 5 is the only user-visible change. Anyone with a custom `keysDir` or
|
||||||
|
`tmpDir` currently has a **split vault** — `generate` and `list` on the
|
||||||
|
configured directory, `encrypt` and `decrypt` on `<vaultRoot>/keys` and
|
||||||
|
`<vaultRoot>/tmp`. After Phase 5 all five agree on the configured directory, so
|
||||||
|
anything written by `encrypt` before the upgrade needs moving:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mv <vaultRoot>/keys/* <vaultRoot>/<keysDir>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nobody on the defaults is affected, since the two halves coincide there. The
|
||||||
|
README gets this as a note, and it is worth a line in the release notes for
|
||||||
|
whichever version carries Phase 5.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Per `CLAUDE.md`: bump `packages/keyman/package.json`, land on `main`, then tag
|
||||||
|
`keyman-v<version>`.
|
||||||
|
|
||||||
|
- **Snapshots come free.** Every push to `main` publishes
|
||||||
|
`<version>-main.<run>.g<sha>` to Gitea under the `main` dist-tag, so each phase
|
||||||
|
is installable for testing without a release. `pnpm run try:snapshot` installs
|
||||||
|
one into a throwaway project.
|
||||||
|
- **Suggested cut points.** After Phase 4 as `0.6.0` — error boundary, guards,
|
||||||
|
recipient handling and the data-loss fix, which is the set worth getting to
|
||||||
|
users first. After Phase 9 as `0.7.0`, carrying the Phase 5 migration note.
|
||||||
|
- **keyman can reach npmjs.** It has no `workspace:*` dependencies (`execa`,
|
||||||
|
`inquirer`, `semver`, `zod` only), so `scripts/linked-deps.mjs` has nothing to
|
||||||
|
block on — unlike `nopy`, which `CLAUDE.md` records as gated behind
|
||||||
|
`nopy-cubes` shipping. keyman has never been published to npmjs; `0.6.0` could
|
||||||
|
be the first, and versions being `0.x.y` rather than `1.0.0-alphaN` means the
|
||||||
|
`latest` dist-tag will now actually move.
|
||||||
|
- **`pnpm publish`, never `npm publish`** — no `workspace:` ranges here, but the
|
||||||
|
rule is repo-wide and `scripts/verify-pack.mjs` enforces it in both workflows.
|
||||||
|
|
||||||
|
## Sequencing at a glance
|
||||||
|
|
||||||
|
```
|
||||||
|
1 cli boundary + --help + args §3.1 §3.2 §1.2(half) isolated, pure addition
|
||||||
|
2 guards in encrypt/decrypt/list §1.2 §1.5 §1.10 new tests only
|
||||||
|
3 age recipient, once and derived §1.3 §2.3 §5.5 signature → async
|
||||||
|
4 decrypt: no clobber, no 0644 §1.4 §2.1 reworks decrypt.test.ts
|
||||||
|
── cut 0.6.0 ──
|
||||||
|
5 thread keysDir/tmpDir §1.1 §5.1 rewrites 4 assertions
|
||||||
|
6 generate: -N gone, .pub recovery §2.2 §1.6 §1.10 reworks generate.test.ts
|
||||||
|
7 config: warn, prune, print §3.5 §3.4 §3.3* *decision
|
||||||
|
8 portability + hygiene §1.7 §1.8 §1.9 §2.4 §1.8 needs sizing
|
||||||
|
9 documentation §5.2 §5.3 §5.4 §5.5 README is the shipped one
|
||||||
|
── cut 0.7.0 ──
|
||||||
|
10 rotation §3.6* *decision: build or delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Phases 1–6 are repairs and want to land in order. 7 and 8 are independent of each
|
||||||
|
other and of 5–6. 9 depends on everything before it. 10 is optional and gates
|
||||||
|
one line of Phase 9.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
Neither blocks Phase 1. Both change scope where they land:
|
||||||
|
|
||||||
|
1. **§3.3, at Phase 7** — delete the inert `resolution` machinery (recommended,
|
||||||
|
−45 lines) or keep it as future shape with a comment saying so.
|
||||||
|
2. **§3.6, at Phase 10** — build rotation (recommended) or delete the README
|
||||||
|
claim in Phase 9.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bitstack/keyman",
|
"name": "@bitsquare/keyman",
|
||||||
"version": "1.0.0",
|
"version": "0.7.0",
|
||||||
"description": "A system to simplify ssh key management",
|
"description": "A system to simplify ssh key management",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ssh",
|
"ssh",
|
||||||
@@ -55,10 +55,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"execa": "^10.0.0",
|
"execa": "^10.0.0",
|
||||||
"inquirer": "^14.0.2",
|
"inquirer": "^14.0.2",
|
||||||
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/semver": "^7.7.1",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
#!/usr/bin/env node
|
/**
|
||||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
* The library surface — `exports["."]`, imported and never executed, which is why
|
||||||
|
* it no longer carries the bin's shebang (AUDIT §4.1). The bin is
|
||||||
|
* `dist/keyman.cli.js`.
|
||||||
|
*
|
||||||
|
* Deliberately narrow: config resolution, the update machinery, and `keyman()` to
|
||||||
|
* run the menu. The operation modules stay internal — every one of them prompts,
|
||||||
|
* prints and spawns, so there is nothing to do with a single one except reproduce
|
||||||
|
* the menu around it (§4.3). The update module is re-exported wholesale rather
|
||||||
|
* than by name list, because the list had drifted to half of it (§4.4).
|
||||||
|
*/
|
||||||
|
export type { KeymanConfig, KeymanConfigFile } from './keyman.config.js';
|
||||||
|
export { describeConfig, loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||||
export * from './keyman.main.js';
|
export * from './keyman.main.js';
|
||||||
|
export * from './keyman.update.js';
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Argv parsing for the keyman CLI.
|
||||||
|
*
|
||||||
|
* Separate from `keyman.cli.ts` because that file is excluded from coverage: it
|
||||||
|
* is meant to be wiring, and *which flag takes a value* and *which channel names
|
||||||
|
* are legal* are behaviour. The old inline `indexOf` reader accepted
|
||||||
|
* `--channel --force`, which reached the registry as a dist-tag that cannot
|
||||||
|
* exist and reported an unreachable registry instead of a bad flag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Channel } from './keyman.update.js';
|
||||||
|
|
||||||
|
/** The channels `--channel` accepts, in the order the error message lists them */
|
||||||
|
export const CHANNELS: readonly Channel[] = ['latest', 'next', 'main'];
|
||||||
|
|
||||||
|
/** Flags that consume the next token, or the suffix of a `--flag=value` */
|
||||||
|
const VALUE_FLAGS: readonly string[] = ['--channel', '--registry'];
|
||||||
|
|
||||||
|
/** Flags that stand alone, short aliases included */
|
||||||
|
const BOOLEAN_FLAGS: readonly string[] = [
|
||||||
|
'--help',
|
||||||
|
'-h',
|
||||||
|
'--version',
|
||||||
|
'-V',
|
||||||
|
'--print-config',
|
||||||
|
'--self-update',
|
||||||
|
'--dry-run',
|
||||||
|
'-n',
|
||||||
|
'--force',
|
||||||
|
'-f',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags that only mean anything to `self-update`. Named so that using one on its
|
||||||
|
* own is an error rather than a silent no-op.
|
||||||
|
*/
|
||||||
|
const SELF_UPDATE_ONLY: readonly string[] = [
|
||||||
|
'--channel',
|
||||||
|
'--registry',
|
||||||
|
'--dry-run',
|
||||||
|
'-n',
|
||||||
|
'--force',
|
||||||
|
'-f',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every flag the parser accepts — the list `helpText()` is checked against */
|
||||||
|
export const KNOWN_FLAGS: readonly string[] = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||||
|
|
||||||
|
const SUBCOMMANDS: readonly string[] = ['self-update', 'upgrade'];
|
||||||
|
|
||||||
|
export type ParsedArgs =
|
||||||
|
| { command: 'help' }
|
||||||
|
| { command: 'version' }
|
||||||
|
| { command: 'print-config' }
|
||||||
|
| { command: 'interactive' }
|
||||||
|
| {
|
||||||
|
command: 'self-update';
|
||||||
|
dryRun: boolean;
|
||||||
|
force: boolean;
|
||||||
|
channel?: Channel;
|
||||||
|
registry?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mistake in the invocation. Carries a message meant for the user, so the CLI
|
||||||
|
* can print one line instead of a stack trace.
|
||||||
|
*/
|
||||||
|
export class UsageError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'UsageError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns argv (already sliced past `node` and the script) into one command.
|
||||||
|
*
|
||||||
|
* @throws {UsageError} on an unknown flag or command, a value flag with no
|
||||||
|
* value, a boolean flag given one, or a channel that is not a real channel
|
||||||
|
*/
|
||||||
|
export function parseArgs(argv: string[]): ParsedArgs {
|
||||||
|
// Before tokenising, so that help answers a line it could not otherwise parse.
|
||||||
|
// Exact tokens only: `--registry=--help` is a (bad) registry, not a request.
|
||||||
|
if (argv.some((token) => token === '--help' || token === '-h')) {
|
||||||
|
return { command: 'help' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = new Set<string>();
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
let subcommand: string | undefined;
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index++) {
|
||||||
|
const token = argv[index];
|
||||||
|
|
||||||
|
if (!token.startsWith('-')) {
|
||||||
|
if (!SUBCOMMANDS.includes(token)) {
|
||||||
|
throw new UsageError(`Unknown command: ${token}`);
|
||||||
|
}
|
||||||
|
if (subcommand) {
|
||||||
|
throw new UsageError(`Unexpected argument: ${token}`);
|
||||||
|
}
|
||||||
|
subcommand = token;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equals = token.indexOf('=');
|
||||||
|
const name = equals === -1 ? token : token.slice(0, equals);
|
||||||
|
|
||||||
|
if (VALUE_FLAGS.includes(name)) {
|
||||||
|
// A value that looks like a flag is a forgotten value, not a value —
|
||||||
|
// unless it was written as --flag=-value and therefore meant.
|
||||||
|
const inline = equals === -1 ? undefined : token.slice(equals + 1);
|
||||||
|
const value = inline ?? argv[++index];
|
||||||
|
if (!value || (inline === undefined && value.startsWith('-'))) {
|
||||||
|
throw new UsageError(`${name} expects a value`);
|
||||||
|
}
|
||||||
|
values.set(name, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BOOLEAN_FLAGS.includes(name)) {
|
||||||
|
throw new UsageError(`Unknown flag: ${name}`);
|
||||||
|
}
|
||||||
|
if (equals !== -1) {
|
||||||
|
throw new UsageError(`${name} does not take a value`);
|
||||||
|
}
|
||||||
|
flags.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const given = (...names: string[]) => names.some((name) => flags.has(name));
|
||||||
|
|
||||||
|
const isSelfUpdate = subcommand !== undefined || flags.has('--self-update');
|
||||||
|
|
||||||
|
if (!isSelfUpdate) {
|
||||||
|
const stray = [...values.keys(), ...flags].find((name) => SELF_UPDATE_ONLY.includes(name));
|
||||||
|
if (stray) {
|
||||||
|
throw new UsageError(`${stray} is only valid with \`keyman self-update\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.has('--print-config')) {
|
||||||
|
return { command: 'print-config' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (given('--version', '-V')) {
|
||||||
|
return { command: 'version' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelfUpdate) {
|
||||||
|
const channel = values.get('--channel');
|
||||||
|
if (channel !== undefined && !CHANNELS.includes(channel as Channel)) {
|
||||||
|
throw new UsageError(`Unknown channel: ${channel} (expected ${CHANNELS.join(', ')})`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'self-update',
|
||||||
|
dryRun: given('--dry-run', '-n'),
|
||||||
|
force: given('--force', '-f'),
|
||||||
|
channel: channel as Channel | undefined,
|
||||||
|
registry: values.get('--registry'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: 'interactive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `--help` prints.
|
||||||
|
*
|
||||||
|
* Hand-written rather than generated from the flag tables, so that adding a flag
|
||||||
|
* to the parser without documenting it fails a test instead of shipping.
|
||||||
|
*/
|
||||||
|
export function helpText(): string {
|
||||||
|
return `keyman — SSH key management and an age-encrypted key vault
|
||||||
|
|
||||||
|
Usage
|
||||||
|
keyman start the interactive menu
|
||||||
|
keyman self-update update keyman itself (alias: upgrade)
|
||||||
|
|
||||||
|
Flags
|
||||||
|
-h, --help print this help and exit
|
||||||
|
-V, --version print the version and exit
|
||||||
|
--print-config print the resolved paths and the config files
|
||||||
|
they came from, as JSON, and exit
|
||||||
|
--self-update same as the self-update subcommand
|
||||||
|
|
||||||
|
Flags for self-update
|
||||||
|
--channel <${CHANNELS.join('|')}> channel to update from
|
||||||
|
(default: derived from the running version)
|
||||||
|
--registry <url> registry to query instead of the configured one
|
||||||
|
-n, --dry-run print the install command without running it
|
||||||
|
-f, --force reinstall even when already up to date
|
||||||
|
|
||||||
|
Environment
|
||||||
|
VAULT_ROOT overrides vaultRoot from .keymanrc.json
|
||||||
|
KEYMAN_REGISTRY registry for the update check and self-update
|
||||||
|
KEYMAN_REGISTRY_TOKEN bearer token for a private registry
|
||||||
|
KEYMAN_NO_UPDATE_CHECK set to 1 to skip the once-a-day update check
|
||||||
|
(also skipped whenever CI is set)
|
||||||
|
KEYMAN_PACKAGE_MANAGER npm | pnpm | yarn | bun for the install command
|
||||||
|
|
||||||
|
Configuration is read from .keymanrc.json, merged from the current directory
|
||||||
|
upwards and then from ~/.keymanrc.json.
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import { scanPrivateKeys } from './keyman.keys.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a `.gitignore` beside the vault, once.
|
||||||
|
*
|
||||||
|
* The README told the user to do this by hand. A vault holds the age identity and,
|
||||||
|
* whenever anything has been decrypted, plaintext private keys — committing it is
|
||||||
|
* the exact failure the tool exists to prevent, and it is one file to prevent it.
|
||||||
|
*
|
||||||
|
* Never overwritten: an existing file may say more than this one does.
|
||||||
|
*/
|
||||||
|
export function writeVaultGitignore(vaultRoot: string, tmpDir: string, keyPath: string) {
|
||||||
|
const gitignore = path.join(vaultRoot, '.gitignore');
|
||||||
|
if (fs.existsSync(gitignore)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both are configurable and may be absolute, so either can sit outside the vault.
|
||||||
|
// A .gitignore cannot speak about a path above itself, and claiming to would be
|
||||||
|
// worse than saying nothing.
|
||||||
|
const inside = (target: string) => {
|
||||||
|
const relative = path.relative(vaultRoot, target);
|
||||||
|
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tmp = inside(tmpDir);
|
||||||
|
const key = inside(keyPath);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'# Written by keyman. The encrypted keys under the keys directory are safe to',
|
||||||
|
'# commit; nothing else here is.',
|
||||||
|
...(key ? [key, `${key}.pub`] : []),
|
||||||
|
...(tmp ? [`${tmp}/`] : []),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(gitignore, lines.join('\n'), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the decrypted keys in the vault's tmp directory.
|
||||||
|
*
|
||||||
|
* The counterpart to `decrypt`, which had none: a plaintext private key stayed
|
||||||
|
* there until someone remembered it, and "someone remembered" is not a security
|
||||||
|
* control. Only the key pairs are removed — anything else in the directory is not
|
||||||
|
* keyman's to delete.
|
||||||
|
*/
|
||||||
|
export async function clearDecryptedKeys(tmpDir: string) {
|
||||||
|
const { keys } = scanPrivateKeys(tmpDir);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
console.log(`✅ Nothing decrypted in ${tmpDir}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🔓 Decrypted keys in ${tmpDir}:`);
|
||||||
|
for (const key of keys) {
|
||||||
|
console.log(` ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'confirmed',
|
||||||
|
message: `Delete ${keys.length === 1 ? 'this key' : `these ${keys.length} keys`}?`,
|
||||||
|
// A key that exists only here — generated and not yet deployed — is gone for
|
||||||
|
// good, so this is not a question to answer by pressing return.
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log('⏭️ Nothing was deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
for (const file of [key, `${key}.pub`]) {
|
||||||
|
fs.rmSync(path.join(tmpDir, file), { force: true });
|
||||||
|
}
|
||||||
|
console.log(`🧹 Removed ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,98 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
import { createRequire } from 'node:module';
|
||||||
|
import { helpText, type ParsedArgs, parseArgs, UsageError } from './keyman.args.js';
|
||||||
|
import { describeConfig } from './keyman.config.js';
|
||||||
import { keyman } from './keyman.main.js';
|
import { keyman } from './keyman.main.js';
|
||||||
|
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
|
||||||
|
version: string;
|
||||||
|
buildInfo?: { commit?: string };
|
||||||
|
};
|
||||||
|
|
||||||
if (args.includes('--print-config')) {
|
/**
|
||||||
const config = loadConfig();
|
* What `--version` prints. `version` itself stays untouched everywhere else —
|
||||||
const paths = resolveConfigPaths(config);
|
* the commit is an annotation, stamped into `package.json` on the runner by the
|
||||||
console.log(JSON.stringify(paths));
|
* publish workflows and absent when running from source.
|
||||||
|
*/
|
||||||
|
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
|
||||||
|
|
||||||
|
let parsed: ParsedArgs;
|
||||||
|
try {
|
||||||
|
parsed = parseArgs(process.argv.slice(2));
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UsageError)) throw error;
|
||||||
|
console.error(`❌ ${error.message}`);
|
||||||
|
console.error('Run `keyman --help` for usage.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.command === 'help') {
|
||||||
|
console.log(helpText());
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
keyman();
|
if (parsed.command === 'print-config') {
|
||||||
|
console.log(JSON.stringify(describeConfig()));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.command === 'version') {
|
||||||
|
console.log(versionLabel);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.command === 'self-update') {
|
||||||
|
const { dryRun } = parsed;
|
||||||
|
try {
|
||||||
|
const result = await selfUpdate({
|
||||||
|
currentVersion: version,
|
||||||
|
channel: parsed.channel,
|
||||||
|
registry: parsed.registry,
|
||||||
|
dryRun,
|
||||||
|
force: parsed.force,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status } = result;
|
||||||
|
console.log(`Installed: ${status.current}`);
|
||||||
|
console.log(`Channel: ${status.channel}`);
|
||||||
|
console.log(`Registry: ${status.registry}`);
|
||||||
|
console.log(`Available: ${status.latest ?? 'unknown'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (result.ran) {
|
||||||
|
console.log(`Updated to ${status.latest}.`);
|
||||||
|
} else if (dryRun) {
|
||||||
|
console.log(`Would run: ${formatCommand(result.command)}`);
|
||||||
|
} else if (status.latest === null) {
|
||||||
|
console.error(`Could not reach ${status.registry} — nothing was changed.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('Already up to date.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printed to stderr so it never mixes into machine-read output.
|
||||||
|
const notice = await updateNotice({ currentVersion: version });
|
||||||
|
if (notice) {
|
||||||
|
console.error(`\n${notice}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await keyman();
|
||||||
|
} catch (error) {
|
||||||
|
// Ctrl-C at any inquirer prompt lands here. `name`, not `instanceof`:
|
||||||
|
// @inquirer/core is transitive and does not resolve from this package.
|
||||||
|
if ((error as { name?: string }).name === 'ExitPromptError') {
|
||||||
|
console.log('\n👋 Goodbye!\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error(`❌ ${error instanceof Error ? error.message : error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { runTool, ToolNotFoundError } from './keyman.utils.js';
|
||||||
|
|
||||||
|
/** A clipboard command and the argv it wants, in the order they are tried. */
|
||||||
|
interface ClipboardTool {
|
||||||
|
binary: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clipboard commands worth trying on a platform, best first.
|
||||||
|
*
|
||||||
|
* Linux is a list rather than a choice because there is no single answer:
|
||||||
|
* `wl-copy` under Wayland, `xclip`/`xsel` under X11, and a user may have any
|
||||||
|
* subset installed. Trying them in order and moving on from an absent one costs a
|
||||||
|
* failed spawn and removes the need to detect the session type.
|
||||||
|
*/
|
||||||
|
export function clipboardTools(platform: string = process.platform): ClipboardTool[] {
|
||||||
|
switch (platform) {
|
||||||
|
case 'darwin':
|
||||||
|
return [{ binary: 'pbcopy', args: [] }];
|
||||||
|
case 'win32':
|
||||||
|
return [{ binary: 'clip', args: [] }];
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
{ binary: 'wl-copy', args: [] },
|
||||||
|
{ binary: 'xclip', args: ['-selection', 'clipboard'] },
|
||||||
|
{ binary: 'xsel', args: ['--clipboard', '--input'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts `text` on the system clipboard.
|
||||||
|
*
|
||||||
|
* keyman used to spawn `pbcopy` unconditionally, with a comment saying so — which
|
||||||
|
* made "copy public key" a dead end on every platform but macOS, and reported it
|
||||||
|
* as a clipboard failure rather than as a missing tool.
|
||||||
|
*
|
||||||
|
* @returns the command that took it, or null if none was available
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string, platform?: string): Promise<string | null> {
|
||||||
|
for (const { binary, args } of clipboardTools(platform)) {
|
||||||
|
try {
|
||||||
|
await runTool(binary, args, { input: text });
|
||||||
|
return binary;
|
||||||
|
} catch (error) {
|
||||||
|
// Only an absent tool is worth trying the next candidate for. One that ran
|
||||||
|
// and refused has an opinion, and repeating the paste elsewhere is not it.
|
||||||
|
if (!(error instanceof ToolNotFoundError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -15,27 +15,11 @@ const KeymanConfigSchema = z.object({
|
|||||||
|
|
||||||
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
||||||
|
|
||||||
/**
|
/** Raw config file structure */
|
||||||
* Resolution strategy for merging config properties
|
export type KeymanConfigFile = Partial<KeymanConfig>;
|
||||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
|
||||||
* - 'override': Child value completely replaces parent value
|
|
||||||
*/
|
|
||||||
export type ResolutionStrategy = 'merge' | 'override';
|
|
||||||
|
|
||||||
/**
|
/** Every key a config file may set. */
|
||||||
* Resolution configuration for customizing merge behavior
|
const KNOWN_KEYS = Object.keys(KeymanConfigSchema.shape) as (keyof KeymanConfig)[];
|
||||||
*/
|
|
||||||
export type KeymanResolutionConfig = {
|
|
||||||
[K in keyof KeymanConfig]?: ResolutionStrategy;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw config file structure (includes resolution)
|
|
||||||
*/
|
|
||||||
export interface KeymanConfigFile extends Partial<KeymanConfig> {
|
|
||||||
/** Customize merge behavior for specific properties */
|
|
||||||
resolution?: KeymanResolutionConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default configuration values
|
* Default configuration values
|
||||||
@@ -110,71 +94,36 @@ function findConfigFiles(startDir: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep merges two values based on resolution strategy
|
* Reports keys a config file sets that keyman does not read.
|
||||||
|
*
|
||||||
|
* `z.object` strips them silently, so `{"vaultroot": "…"}` used to be
|
||||||
|
* indistinguishable from an empty file — the vault quietly stayed at the default
|
||||||
|
* and nothing said why. Warned rather than fatal, which is this module's posture
|
||||||
|
* throughout, and warned *here* because this is the only place the filename is in
|
||||||
|
* hand: `z.strictObject` on the merged result cannot name the file that said it.
|
||||||
*/
|
*/
|
||||||
function mergeValue(
|
function warnUnknownKeys(configFile: KeymanConfigFile, configPath: string): void {
|
||||||
parentValue: unknown,
|
const unknown = Object.keys(configFile).filter(
|
||||||
childValue: unknown,
|
(key) => !KNOWN_KEYS.includes(key as keyof KeymanConfig)
|
||||||
strategy: ResolutionStrategy
|
);
|
||||||
): unknown {
|
|
||||||
// Override strategy: child replaces parent completely
|
|
||||||
if (strategy === 'override') {
|
|
||||||
return childValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge strategy (default)
|
if (unknown.length > 0) {
|
||||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
console.warn(
|
||||||
// Concatenate arrays, remove duplicates for primitives
|
`⚠️ ${configPath}: ignoring unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.join(', ')}. Known keys: ${KNOWN_KEYS.join(', ')}.`
|
||||||
const combined = [...parentValue, ...childValue];
|
);
|
||||||
if (combined.every((v) => typeof v !== 'object')) {
|
|
||||||
return [...new Set(combined)];
|
|
||||||
}
|
|
||||||
return combined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
typeof parentValue === 'object' &&
|
|
||||||
parentValue !== null &&
|
|
||||||
typeof childValue === 'object' &&
|
|
||||||
childValue !== null &&
|
|
||||||
!Array.isArray(parentValue) &&
|
|
||||||
!Array.isArray(childValue)
|
|
||||||
) {
|
|
||||||
// Deep merge objects
|
|
||||||
const result: Record<string, unknown> = { ...parentValue };
|
|
||||||
for (const [key, value] of Object.entries(childValue)) {
|
|
||||||
if (key in result) {
|
|
||||||
result[key] = mergeValue(result[key], value, 'merge');
|
|
||||||
} else {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primitives: child overrides parent
|
|
||||||
return childValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges a child config into a parent config
|
* Merges a child config into a parent config.
|
||||||
|
*
|
||||||
|
* Every property is a string, so a child simply wins. keyman deliberately has
|
||||||
|
* none of nopy's `resolution` machinery: deep-merge and array-concatenation
|
||||||
|
* strategies are meaningful there because its config holds arrays and objects,
|
||||||
|
* and here they would be 45 lines that cannot change an outcome.
|
||||||
*/
|
*/
|
||||||
function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig {
|
function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig {
|
||||||
const resolution = childFile.resolution || {};
|
return { ...parent, ...childFile };
|
||||||
const result: Record<string, unknown> = { ...parent };
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(childFile)) {
|
|
||||||
if (key === 'resolution') continue; // Skip resolution property itself
|
|
||||||
|
|
||||||
const strategy = resolution[key as keyof KeymanConfig] || 'merge';
|
|
||||||
if (key in result) {
|
|
||||||
result[key] = mergeValue(result[key], value, strategy);
|
|
||||||
} else {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result as unknown as KeymanConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,16 +132,6 @@ function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): Keyman
|
|||||||
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
||||||
* Multiple config files are merged, with child configs overriding parent configs.
|
* Multiple config files are merged, with child configs overriding parent configs.
|
||||||
*
|
*
|
||||||
* Use the `resolution` property to customize merge behavior:
|
|
||||||
* ```json
|
|
||||||
* {
|
|
||||||
* "vaultRoot": "../vault",
|
|
||||||
* "resolution": {
|
|
||||||
* "vaultRoot": "override"
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @returns Validated keyman configuration
|
* @returns Validated keyman configuration
|
||||||
*/
|
*/
|
||||||
export function loadConfig(): KeymanConfig {
|
export function loadConfig(): KeymanConfig {
|
||||||
@@ -211,6 +150,7 @@ export function loadConfig(): KeymanConfig {
|
|||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(configPath, 'utf-8');
|
const content = fs.readFileSync(configPath, 'utf-8');
|
||||||
const rawConfig = JSON.parse(content) as KeymanConfigFile;
|
const rawConfig = JSON.parse(content) as KeymanConfigFile;
|
||||||
|
warnUnknownKeys(rawConfig, configPath);
|
||||||
// Resolve path properties relative to the config file's directory
|
// Resolve path properties relative to the config file's directory
|
||||||
const configDir = path.dirname(configPath);
|
const configDir = path.dirname(configPath);
|
||||||
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
||||||
@@ -265,3 +205,17 @@ export function resolveConfigPaths(config: KeymanConfig) {
|
|||||||
export function getConfigPaths(): string[] {
|
export function getConfigPaths(): string[] {
|
||||||
return findConfigFiles(process.cwd());
|
return findConfigFiles(process.cwd());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `--print-config` prints.
|
||||||
|
*
|
||||||
|
* `configFiles` is the question the flag could not answer before: which files
|
||||||
|
* were read, in the order they were merged. It existed only as unstructured
|
||||||
|
* stderr from `loadConfig`, which is exactly the wrong place for it — the JSON is
|
||||||
|
* the machine-readable half.
|
||||||
|
*/
|
||||||
|
export function describeConfig(): ReturnType<typeof resolveConfigPaths> & {
|
||||||
|
configFiles: string[];
|
||||||
|
} {
|
||||||
|
return { ...resolveConfigPaths(loadConfig()), configFiles: getConfigPaths() };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execa } from 'execa';
|
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
|
import { copyToClipboard } from './keyman.clipboard.js';
|
||||||
|
import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js';
|
||||||
|
|
||||||
export async function copyKey(sshDir: string, tmpDir: string) {
|
export async function copyKey(sshDir: string, tmpDir: string) {
|
||||||
const getKeys = (dir: string) => {
|
const ssh = scanPrivateKeys(sshDir);
|
||||||
if (!fs.existsSync(dir)) return [];
|
const tmp = scanPrivateKeys(tmpDir);
|
||||||
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sshKeys = getKeys(sshDir);
|
const keys = [...new Set([...ssh.keys, ...tmp.keys])];
|
||||||
const tmpKeys = getKeys(tmpDir);
|
|
||||||
|
|
||||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
// Before the empty check: "no SSH keys found" next to four unmanageable ones is
|
||||||
|
// the case the report exists for.
|
||||||
|
reportSkippedKeys(ssh.skipped, sshDir);
|
||||||
|
reportSkippedKeys(tmp.skipped, tmpDir);
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
console.log('⚠️ No SSH keys found.');
|
console.log('⚠️ No SSH keys found.');
|
||||||
@@ -40,19 +41,23 @@ export async function copyKey(sshDir: string, tmpDir: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
const tool = await copyToClipboard(pubKeyContent);
|
||||||
|
|
||||||
// Detect OS and use appropriate clipboard command
|
if (tool) {
|
||||||
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
|
console.log(`✅ Public key for ${selectedKey} copied to clipboard via ${tool}!`);
|
||||||
// For this specific request on Darwin:
|
return;
|
||||||
const proc = execa('pbcopy');
|
}
|
||||||
proc.stdin?.write(pubKeyContent);
|
console.warn('⚠️ No clipboard command found.');
|
||||||
proc.stdin?.end();
|
|
||||||
await proc;
|
|
||||||
|
|
||||||
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Failed to copy to clipboard: ${error}`);
|
console.error(
|
||||||
|
`❌ Failed to copy to clipboard: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Printing it is the point of the operation; the clipboard was only the
|
||||||
|
// convenient way to deliver it. A public key is not a secret.
|
||||||
|
console.log(`\n${pubKeyContent}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execa } from 'execa';
|
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
|
import { runTool } from './keyman.utils.js';
|
||||||
|
import { listVaultKeys } from './keyman.vault.js';
|
||||||
|
|
||||||
export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) {
|
/** The two decryption targets. Values, so the label can name the real directory. */
|
||||||
const keyDir = path.join(vaultDir, 'keys');
|
const LOCAL_MODE = 'local';
|
||||||
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
|
|
||||||
const keyfile = path.join(keyDir, key, `id_${key}.age`);
|
interface DecryptPlan {
|
||||||
console.log(keyfile);
|
key: string;
|
||||||
return fs.existsSync(keyfile);
|
encryptedKey: string;
|
||||||
});
|
publicKey: string;
|
||||||
|
privateKeyOut: string;
|
||||||
|
publicKeyOut: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: string, ageKey: string) {
|
||||||
|
const vaultKeys = listVaultKeys(keysDir);
|
||||||
|
|
||||||
if (vaultKeys.length === 0) {
|
if (vaultKeys.length === 0) {
|
||||||
console.log('⚠️ No encrypted keys found.');
|
console.log('⚠️ No encrypted keys found.');
|
||||||
@@ -27,27 +34,75 @@ export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: stri
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
name: 'decryptMode',
|
name: 'decryptMode',
|
||||||
message: 'Choose decryption location:',
|
message: 'Choose decryption location:',
|
||||||
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
|
// Named after the directories actually in use, which are configurable.
|
||||||
|
choices: [
|
||||||
|
{ name: `Local (${tmpDir})`, value: LOCAL_MODE },
|
||||||
|
{ name: `SSH (${sshDir})`, value: 'ssh' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const key of selectedKeys) {
|
const outDir = decryptMode === LOCAL_MODE ? tmpDir : sshDir;
|
||||||
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
|
|
||||||
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
|
|
||||||
const privateKeyOut =
|
|
||||||
decryptMode === 'Local (vault/tmp)'
|
|
||||||
? path.join(vaultDir, 'tmp', `id_${key}`)
|
|
||||||
: path.join(sshDir, `id_${key}`);
|
|
||||||
const publicKeyOut =
|
|
||||||
decryptMode === 'Local (vault/tmp)'
|
|
||||||
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
|
|
||||||
: path.join(sshDir, `id_${key}.pub`);
|
|
||||||
|
|
||||||
// Decrypt key
|
const plans: DecryptPlan[] = selectedKeys.map((key: string) => ({
|
||||||
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
key,
|
||||||
|
encryptedKey: path.join(keysDir, key, `id_${key}.age`),
|
||||||
|
publicKey: path.join(keysDir, key, `id_${key}.pub`),
|
||||||
|
privateKeyOut: path.join(outDir, `id_${key}`),
|
||||||
|
publicKeyOut: path.join(outDir, `id_${key}.pub`),
|
||||||
|
}));
|
||||||
|
|
||||||
await execa('cp', [publicKey, publicKeyOut]);
|
// Every collision is settled before anything is written. `age -d -o` and the
|
||||||
await execa('chmod', ['600', privateKeyOut]);
|
// old `cp` both overwrote silently, so decrypting a vault key on top of a
|
||||||
console.log(`✅ Decrypted: ${privateKeyOut}`);
|
// newer working key destroyed it with no prompt and no copy — and the user is
|
||||||
|
// answering these questions about files that still exist.
|
||||||
|
const approved: DecryptPlan[] = [];
|
||||||
|
for (const plan of plans) {
|
||||||
|
const existing = [plan.privateKeyOut, plan.publicKeyOut].filter((file) => fs.existsSync(file));
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
approved.push(plan);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overwrite } = await inquirer.prompt<{ overwrite: boolean }>([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'overwrite',
|
||||||
|
message: `${existing.join(', ')} already present. Overwrite?`,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (overwrite) {
|
||||||
|
approved.push(plan);
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ Skipped ${plan.key} — kept what was already there.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approved.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0700: ~/.ssh may not exist yet, and it is about to hold a private key.
|
||||||
|
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
for (const plan of approved) {
|
||||||
|
await runTool('age', ['-d', '-i', ageKey, '-o', plan.privateKeyOut, plan.encryptedKey]);
|
||||||
|
// Immediately, and in-process: age creates its output 0644 regardless of
|
||||||
|
// umask, so this used to be a world-readable private key for the length of
|
||||||
|
// two process spawns — and stayed 0644 whenever the chmod itself failed.
|
||||||
|
fs.chmodSync(plan.privateKeyOut, 0o600);
|
||||||
|
|
||||||
|
if (fs.existsSync(plan.publicKey)) {
|
||||||
|
fs.copyFileSync(plan.publicKey, plan.publicKeyOut);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`⚠️ ${plan.key} has no public key in the vault; only the private key was written.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Decrypted: ${plan.privateKeyOut}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execa } from 'execa';
|
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
|
import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js';
|
||||||
|
import { ToolNotFoundError } from './keyman.utils.js';
|
||||||
|
import { storeInVault } from './keyman.vault.js';
|
||||||
|
|
||||||
export async function encryptKeys(
|
export async function encryptKeys(sshDir: string, keysDir: string, tmpDir: string, pubkey: string) {
|
||||||
sshDir: string,
|
const ssh = scanPrivateKeys(sshDir);
|
||||||
vaultDir: string,
|
const tmp = scanPrivateKeys(tmpDir);
|
||||||
tmpDir: string,
|
const sshKeys = ssh.keys;
|
||||||
pubkey: string
|
const tmpKeys = tmp.keys;
|
||||||
) {
|
|
||||||
const sshKeys = fs
|
|
||||||
.readdirSync(sshDir)
|
|
||||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
|
||||||
const tmpKeys = fs
|
|
||||||
.readdirSync(tmpDir)
|
|
||||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
|
||||||
console.log(tmpKeys);
|
|
||||||
console.log(sshKeys);
|
|
||||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||||
|
|
||||||
|
reportSkippedKeys(ssh.skipped, sshDir);
|
||||||
|
reportSkippedKeys(tmp.skipped, tmpDir);
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
console.log('⚠️ No private SSH keys found to encrypt.');
|
console.log('⚠️ No private SSH keys found to encrypt.');
|
||||||
return;
|
return;
|
||||||
@@ -33,17 +28,30 @@ export async function encryptKeys(
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
for (const key of selectedKeys) {
|
for (const key of selectedKeys) {
|
||||||
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
|
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
|
||||||
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', ''));
|
|
||||||
fs.mkdirSync(vaultPath, { recursive: true });
|
|
||||||
|
|
||||||
// Encrypt key using `age`
|
try {
|
||||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
|
await storeInVault(keyPath, keysDir, pubkey);
|
||||||
|
} catch (error) {
|
||||||
|
// One bad key costs one key. Selecting ten and losing the last nine to an
|
||||||
|
// unreadable first one was the old behaviour, and nothing afterwards said
|
||||||
|
// which of the ten had made it into the vault.
|
||||||
|
if (error instanceof ToolNotFoundError) {
|
||||||
|
// Not a per-key problem: age is missing for all of them, so nine more
|
||||||
|
// identical failures would tell the user nothing new.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
failed.push(key);
|
||||||
|
console.error(`❌ ${key}: ${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Copy public key and create README
|
if (failed.length > 0) {
|
||||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
console.log(
|
||||||
|
`\n⚠️ ${failed.length} of ${selectedKeys.length} selected keys were not stored: ${failed.join(', ')}`
|
||||||
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execa } from 'execa';
|
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
|
import { runTool } from './keyman.utils.js';
|
||||||
|
import { storeInVault } from './keyman.vault.js';
|
||||||
|
|
||||||
export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) {
|
export interface KeyOptions {
|
||||||
|
algorithm: string;
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a new key pair should be made: the algorithm and the comment.
|
||||||
|
*
|
||||||
|
* Shared with rotation, which asks the same two questions about a key whose name
|
||||||
|
* it works out for itself.
|
||||||
|
*
|
||||||
|
* @param defaultIdentity offered as the answer — the comment of the key being
|
||||||
|
* replaced, when there is one
|
||||||
|
*/
|
||||||
|
export async function promptKeyOptions(defaultIdentity?: string): Promise<KeyOptions> {
|
||||||
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
@@ -14,6 +29,57 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const { identity } = await inquirer.prompt<{ identity: string }>([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'identity',
|
||||||
|
message: 'Enter key identity (comment):',
|
||||||
|
default: defaultIdentity,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { algorithm, identity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates one key pair at `keyPath`, reporting a failure rather than throwing.
|
||||||
|
*
|
||||||
|
* @returns whether the key pair was written
|
||||||
|
*/
|
||||||
|
export async function createKeyPair(
|
||||||
|
keyPath: string,
|
||||||
|
algorithm: string,
|
||||||
|
identity: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const fileName = path.basename(keyPath);
|
||||||
|
|
||||||
|
if (fs.existsSync(keyPath)) {
|
||||||
|
console.error(`❌ Error: Key file ${fileName} already exists in ${path.dirname(keyPath)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['-t', algorithm, '-f', keyPath, '-C', identity];
|
||||||
|
if (algorithm === 'rsa') {
|
||||||
|
args.push('-b', '4096');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Generating ${algorithm} key pair...`);
|
||||||
|
// No `-N`, and stdio inherited: ssh-keygen asks for the passphrase itself and
|
||||||
|
// confirms it. keyman used to prompt for it and pass it as `-N <value>`,
|
||||||
|
// which put the passphrase in this process's argv — readable by any user on
|
||||||
|
// the box via `ps` for as long as the spawn lived, and in keyman's memory
|
||||||
|
// before that. A passphrase keyman never learns cannot be leaked by keyman.
|
||||||
|
await runTool('ssh-keygen', args, { stdio: 'inherit' });
|
||||||
|
console.log(`✅ Key generated: ${keyPath}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error generating key: ${error instanceof Error ? error.message : error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) {
|
||||||
const { keyName } = await inquirer.prompt<{ keyName: string }>([
|
const { keyName } = await inquirer.prompt<{ keyName: string }>([
|
||||||
{
|
{
|
||||||
type: 'input',
|
type: 'input',
|
||||||
@@ -23,55 +89,21 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { password } = await inquirer.prompt<{ password: string }>([
|
const { algorithm, identity } = await promptKeyOptions();
|
||||||
{
|
|
||||||
type: 'password',
|
|
||||||
name: 'password',
|
|
||||||
message: 'Enter passphrase (leave empty for no passphrase):',
|
|
||||||
mask: '*',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { identity } = await inquirer.prompt<{ identity: string }>([
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
name: 'identity',
|
|
||||||
message: 'Enter key identity (comment):',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||||
const keyPath = path.join(tmpDir, fileName);
|
const keyPath = path.join(tmpDir, fileName);
|
||||||
|
|
||||||
if (fs.existsSync(keyPath)) {
|
if (!(await createKeyPair(keyPath, algorithm, identity))) {
|
||||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Generating ${algorithm} key pair...`);
|
await storeInVault(keyPath, keysDir, pubkey);
|
||||||
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
|
|
||||||
|
|
||||||
if (algorithm === 'rsa') {
|
|
||||||
args.push('-b', '4096');
|
|
||||||
}
|
|
||||||
|
|
||||||
await execa('ssh-keygen', args);
|
|
||||||
console.log(`✅ Key generated: ${keyPath}`);
|
|
||||||
|
|
||||||
// Encrypt the key
|
|
||||||
const folderName = fileName.replace('id_', '');
|
|
||||||
const vaultPath = path.join(keysDir, folderName);
|
|
||||||
fs.mkdirSync(vaultPath, { recursive: true });
|
|
||||||
|
|
||||||
// Encrypt key using `age`
|
|
||||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
|
|
||||||
|
|
||||||
// Copy public key
|
|
||||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
|
|
||||||
|
|
||||||
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error generating/encrypting key: ${error}`);
|
// The private key is still in tmpDir, so this is recoverable by encrypting it
|
||||||
|
// — which is why it does not read as having lost the key.
|
||||||
|
console.error(`❌ Error encrypting key: ${error instanceof Error ? error.message : error}`);
|
||||||
|
console.error(` ${keyPath} was generated; encrypt it once the problem is fixed.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** The answer the USER prompt defaults to: whoever is running keyman. */
|
||||||
|
export const CURRENT_USER = '@current';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home directory of the current user.
|
||||||
|
*
|
||||||
|
* `HOME` first, because a user who set it meant it, and `os.userInfo()` after,
|
||||||
|
* which reads the passwd database and so still answers when `HOME` is unset — a
|
||||||
|
* cron job, a `su` without `-l`, a container entrypoint. `process.env.HOME || ''`
|
||||||
|
* treated all of those as a fatal error.
|
||||||
|
*/
|
||||||
|
function currentHome(): string | null {
|
||||||
|
if (process.env.HOME) {
|
||||||
|
return process.env.HOME;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return os.userInfo().homedir || null;
|
||||||
|
} catch {
|
||||||
|
// uv_os_get_passwd can fail outright when there is no passwd entry for the uid.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where another user's home directory is, without asking the system.
|
||||||
|
*
|
||||||
|
* The sibling of the current user's home comes first because it is right wherever
|
||||||
|
* homes live together, whatever that directory is called — `/Users` on macOS,
|
||||||
|
* `/home` on Linux, `/export/home` on the odd installation. keyman previously
|
||||||
|
* hardcoded `/home/<user>`, which is wrong on the one platform it was written on.
|
||||||
|
*
|
||||||
|
* The candidates are checked for existence rather than guessed at, so a wrong one
|
||||||
|
* produces an error naming what was tried instead of an empty `readdir`.
|
||||||
|
*/
|
||||||
|
function candidateHomes(user: string): string[] {
|
||||||
|
const home = currentHome();
|
||||||
|
const siblings = home ? [path.join(path.dirname(home), user)] : [];
|
||||||
|
|
||||||
|
return [...new Set([...siblings, path.join('/home', user), path.join('/Users', user)])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the home directory for an answer to the USER prompt.
|
||||||
|
*
|
||||||
|
* @returns the directory, or null with the reason already reported
|
||||||
|
*/
|
||||||
|
export function resolveHomeDir(user: string): string | null {
|
||||||
|
if (user === CURRENT_USER) {
|
||||||
|
const home = currentHome();
|
||||||
|
if (!home) {
|
||||||
|
console.error('❌ Unable to determine HOME directory for the current user.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = candidateHomes(user);
|
||||||
|
const found = candidates.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
console.error(`❌ No home directory found for ${user}. Tried: ${candidates.join(', ')}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** Present in the first line of every private key format ssh-keygen writes. */
|
||||||
|
const PRIVATE_KEY_MARKER = 'PRIVATE KEY-----';
|
||||||
|
|
||||||
|
/** Enough for `-----BEGIN OPENSSH PRIVATE KEY-----`, and no more of a key than needed. */
|
||||||
|
const HEADER_BYTES = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a file opens with a private key header.
|
||||||
|
*
|
||||||
|
* A bounded read of the first line, not the file: classifying a key is no reason
|
||||||
|
* to pull one into memory.
|
||||||
|
*/
|
||||||
|
function looksLikePrivateKey(file: string): boolean {
|
||||||
|
let handle: number | undefined;
|
||||||
|
try {
|
||||||
|
handle = fs.openSync(file, 'r');
|
||||||
|
const buffer = Buffer.alloc(HEADER_BYTES);
|
||||||
|
const read = fs.readSync(handle, buffer, 0, HEADER_BYTES, 0);
|
||||||
|
return buffer.subarray(0, read).toString('latin1').includes(PRIVATE_KEY_MARKER);
|
||||||
|
} catch {
|
||||||
|
// A directory, a socket, a file with no read permission — none of them a key.
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (handle !== undefined) {
|
||||||
|
fs.closeSync(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivateKeyScan {
|
||||||
|
/** Keys keyman can manage: named `id_*`, which is what the vault layout assumes. */
|
||||||
|
keys: string[];
|
||||||
|
/** Private keys it found and cannot manage, because they are named otherwise. */
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The private keys in a directory that may not exist.
|
||||||
|
*
|
||||||
|
* A first run has neither `~/.ssh` nor the tmp directory, and an unguarded readdir
|
||||||
|
* there threw before the "nothing to encrypt" message could be reached.
|
||||||
|
*
|
||||||
|
* `skipped` exists because the `id_*` filter is silent: a key named
|
||||||
|
* `deploy_ed25519` was simply absent from every menu, and pre-existing keys are
|
||||||
|
* the population a key manager gets adopted to take over. Reporting them is not
|
||||||
|
* managing them — see `reportSkippedKeys`.
|
||||||
|
*/
|
||||||
|
export function scanPrivateKeys(dir: string): PrivateKeyScan {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return { keys: [], skipped: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
// Sorted, because readdir order is the filesystem's business and a menu's order
|
||||||
|
// should not depend on it.
|
||||||
|
for (const file of fs.readdirSync(dir).sort()) {
|
||||||
|
if (file.endsWith('.pub')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.startsWith('id_')) {
|
||||||
|
// Not content-checked: what the menus offered has not changed.
|
||||||
|
keys.push(file);
|
||||||
|
} else if (looksLikePrivateKey(path.join(dir, file))) {
|
||||||
|
skipped.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keys, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Says which private keys were found and left alone, and why.
|
||||||
|
*
|
||||||
|
* The vault stores a key as `<name minus id_>/id_<name>.age` and `decrypt`
|
||||||
|
* reconstructs the filename from the directory, so the prefix is baked into the
|
||||||
|
* on-disk layout — which is why this is a report and not a fix.
|
||||||
|
*/
|
||||||
|
export function reportSkippedKeys(skipped: string[], dir: string): void {
|
||||||
|
if (skipped.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plural = skipped.length === 1 ? 'key' : 'keys';
|
||||||
|
console.log(
|
||||||
|
`ℹ️ Skipped ${skipped.length} private ${plural} in ${dir} not named id_*: ${skipped.join(', ')}`
|
||||||
|
);
|
||||||
|
console.log(' The vault layout requires the id_ prefix; rename to manage them here.');
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js';
|
||||||
|
|
||||||
interface KeyInfo {
|
interface KeyInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -76,9 +77,12 @@ export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string)
|
|||||||
|
|
||||||
// Scan vault directory
|
// Scan vault directory
|
||||||
if (fs.existsSync(vaultDir)) {
|
if (fs.existsSync(vaultDir)) {
|
||||||
|
// throwIfNoEntry keeps a dangling symlink from aborting the whole listing;
|
||||||
|
// the stat still follows a symlink to a real directory, which withFileTypes
|
||||||
|
// would have reported as a link and skipped.
|
||||||
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
||||||
const stat = fs.statSync(path.join(vaultDir, dir));
|
const stat = fs.statSync(path.join(vaultDir, dir), { throwIfNoEntry: false });
|
||||||
return stat.isDirectory();
|
return stat?.isDirectory() ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const dir of vaultDirs) {
|
for (const dir of vaultDirs) {
|
||||||
@@ -102,6 +106,12 @@ export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A listing that omits keys without saying so is the worst place for the id_
|
||||||
|
// assumption to be invisible: this is the screen a user checks it against.
|
||||||
|
for (const dir of [sshDir, tmpDir]) {
|
||||||
|
reportSkippedKeys(scanPrivateKeys(dir).skipped, dir);
|
||||||
|
}
|
||||||
|
|
||||||
// Display results
|
// Display results
|
||||||
if (keyMap.size === 0) {
|
if (keyMap.size === 0) {
|
||||||
console.log('⚠️ No SSH keys found.\n');
|
console.log('⚠️ No SSH keys found.\n');
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
|
import { clearDecryptedKeys, writeVaultGitignore } from './keyman.clear.js';
|
||||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||||
import { copyKey } from './keyman.copy.js';
|
import { copyKey } from './keyman.copy.js';
|
||||||
import { decryptKeys } from './keyman.decrypt.js';
|
import { decryptKeys } from './keyman.decrypt.js';
|
||||||
import { encryptKeys } from './keyman.encrypt.js';
|
import { encryptKeys } from './keyman.encrypt.js';
|
||||||
import { generateKey } from './keyman.generate.js';
|
import { generateKey } from './keyman.generate.js';
|
||||||
|
import { CURRENT_USER, resolveHomeDir } from './keyman.home.js';
|
||||||
import { listKeys } from './keyman.list.js';
|
import { listKeys } from './keyman.list.js';
|
||||||
|
import { retireKey, rotateKey } from './keyman.rotate.js';
|
||||||
import { extractAgePublicKey } from './keyman.utils.js';
|
import { extractAgePublicKey } from './keyman.utils.js';
|
||||||
|
|
||||||
// 🔹 Main function to resolve paths and manage flow
|
// 🔹 Main function to resolve paths and manage flow
|
||||||
@@ -25,20 +28,36 @@ export async function keyman() {
|
|||||||
{
|
{
|
||||||
type: 'input',
|
type: 'input',
|
||||||
name: 'user',
|
name: 'user',
|
||||||
message: 'Specify USER (default: @current):',
|
message: `Specify USER (default: ${CURRENT_USER}):`,
|
||||||
default: '@current',
|
default: CURRENT_USER,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
const homeDir = resolveHomeDir(user);
|
||||||
if (!homeDir) {
|
if (!homeDir) {
|
||||||
console.error('Error: Unable to determine HOME directory.');
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sshDir = path.join(homeDir, '.ssh');
|
const sshDir = path.join(homeDir, '.ssh');
|
||||||
fs.mkdirSync(paths.vaultRoot, { recursive: true });
|
// 0700 because the vault holds the age identity and, in tmp, plaintext private
|
||||||
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
// keys. keysDir is created here too: decrypt used to read it before anything
|
||||||
|
// created it.
|
||||||
|
for (const dir of [paths.vaultRoot, paths.keysDir, paths.tmpDir]) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
writeVaultGitignore(paths.vaultRoot, paths.tmpDir, paths.keyPath);
|
||||||
|
|
||||||
|
// Resolved on demand, because only generate and encrypt need a recipient, and
|
||||||
|
// remembered once it succeeds. Retried while it has not: creating the identity
|
||||||
|
// mid-session should not mean restarting.
|
||||||
|
let recipient: string | null = null;
|
||||||
|
const ageRecipient = async () => {
|
||||||
|
recipient ??= await extractAgePublicKey(paths.keyPath);
|
||||||
|
if (!recipient) {
|
||||||
|
console.error(` Create one with: age-keygen -o ${paths.keyPath}`);
|
||||||
|
}
|
||||||
|
return recipient;
|
||||||
|
};
|
||||||
|
|
||||||
// Main loop - keep showing menu until user quits
|
// Main loop - keep showing menu until user quits
|
||||||
let running = true;
|
let running = true;
|
||||||
@@ -57,6 +76,9 @@ export async function keyman() {
|
|||||||
{ name: '🆕 Generate key', value: 'generate' },
|
{ name: '🆕 Generate key', value: 'generate' },
|
||||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||||
|
{ name: '🔄 Rotate key', value: 'rotate' },
|
||||||
|
{ name: '🗑️ Retire key', value: 'retire' },
|
||||||
|
{ name: '🧹 Clear decrypted keys', value: 'clear' },
|
||||||
{ name: '❌ Quit', value: 'quit' },
|
{ name: '❌ Quit', value: 'quit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -69,19 +91,35 @@ export async function keyman() {
|
|||||||
case 'copy':
|
case 'copy':
|
||||||
await copyKey(sshDir, paths.tmpDir);
|
await copyKey(sshDir, paths.tmpDir);
|
||||||
break;
|
break;
|
||||||
case 'generate':
|
case 'generate': {
|
||||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath)!);
|
const pubkey = await ageRecipient();
|
||||||
|
if (pubkey) {
|
||||||
|
await generateKey(paths.tmpDir, paths.keysDir, pubkey);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'encrypt':
|
}
|
||||||
await encryptKeys(
|
case 'encrypt': {
|
||||||
sshDir,
|
const pubkey = await ageRecipient();
|
||||||
paths.vaultRoot,
|
if (pubkey) {
|
||||||
paths.tmpDir,
|
await encryptKeys(sshDir, paths.keysDir, paths.tmpDir, pubkey);
|
||||||
extractAgePublicKey(paths.keyPath)!
|
}
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'decrypt':
|
case 'decrypt':
|
||||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath);
|
||||||
|
break;
|
||||||
|
case 'rotate': {
|
||||||
|
const pubkey = await ageRecipient();
|
||||||
|
if (pubkey) {
|
||||||
|
await rotateKey(sshDir, paths.keysDir, paths.tmpDir, pubkey);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'retire':
|
||||||
|
await retireKey(sshDir, paths.keysDir, paths.tmpDir);
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
await clearDecryptedKeys(paths.tmpDir);
|
||||||
break;
|
break;
|
||||||
case 'quit':
|
case 'quit':
|
||||||
console.log('\n👋 Goodbye!\n');
|
console.log('\n👋 Goodbye!\n');
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Key rotation, in two halves that are deliberately not one operation.
|
||||||
|
*
|
||||||
|
* `rotateKey` only ever *adds*: a replacement key generated under the next name in
|
||||||
|
* the series and encrypted alongside the key it replaces. `retireKey` is what
|
||||||
|
* finally deletes the old one, once the user says the replacement is deployed.
|
||||||
|
*
|
||||||
|
* Rotating in place — overwriting the key, or deleting it in the same breath —
|
||||||
|
* locks you out of the host you were rotating for: the replacement is not on it
|
||||||
|
* yet, and the only copy of the key that is has gone. The gap between the two
|
||||||
|
* operations is where you add the new public key and check that it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import { createKeyPair, promptKeyOptions } from './keyman.generate.js';
|
||||||
|
import { scanPrivateKeys } from './keyman.keys.js';
|
||||||
|
import { listVaultKeys, storeInVault } from './keyman.vault.js';
|
||||||
|
|
||||||
|
interface Series {
|
||||||
|
base: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `prod-2` → base `prod`, version 2. An unsuffixed name is version 1. */
|
||||||
|
function series(key: string): Series {
|
||||||
|
const match = /^(.+)-(\d+)$/.exec(key);
|
||||||
|
return match ? { base: match[1], version: Number(match[2]) } : { base: key, version: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name for the replacement of `key`: same series, next version up.
|
||||||
|
*
|
||||||
|
* The name has to change. The vault layout derives the directory from it, so a
|
||||||
|
* replacement also called `prod` *is* the `prod` entry — and holding both at once
|
||||||
|
* is the whole point of rotating this way.
|
||||||
|
*
|
||||||
|
* @param taken every name already in use, in the vault or as a plaintext key, so
|
||||||
|
* the suffix skips a version that was made by hand
|
||||||
|
*/
|
||||||
|
export function nextRotationName(key: string, taken: string[]): string {
|
||||||
|
const { base, version } = series(key);
|
||||||
|
let next = version + 1;
|
||||||
|
|
||||||
|
for (const name of taken) {
|
||||||
|
const other = series(name);
|
||||||
|
if (other.base === base && other.version >= next) {
|
||||||
|
next = other.version + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}-${next}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The latest key in the vault that comes after `key` in its series, if any. */
|
||||||
|
export function supersededBy(key: string, vaultKeys: string[]): string | null {
|
||||||
|
const { base, version } = series(key);
|
||||||
|
let successor: string | null = null;
|
||||||
|
let highest = version;
|
||||||
|
|
||||||
|
for (const name of vaultKeys) {
|
||||||
|
const other = series(name);
|
||||||
|
if (other.base === base && other.version > highest) {
|
||||||
|
successor = name;
|
||||||
|
highest = other.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return successor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare names of the plaintext keys in `dir`, matching the vault's naming. */
|
||||||
|
function plaintextNames(dir: string): string[] {
|
||||||
|
return scanPrivateKeys(dir).keys.map((file) => file.replace(/^id_/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The comment on a stored public key, so a rotation can carry it over. */
|
||||||
|
function storedComment(publicKeyFile: string): string | undefined {
|
||||||
|
if (!fs.existsSync(publicKeyFile)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `<type> <base64> <comment...>`: the comment is optional and may hold spaces.
|
||||||
|
const comment = fs.readFileSync(publicKeyFile, 'utf-8').trim().split(/\s+/).slice(2).join(' ');
|
||||||
|
return comment || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints a public key for copying, or says why it cannot. */
|
||||||
|
function showPublicKey(label: string, file: string): void {
|
||||||
|
console.log(`\n ${label}`);
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
console.log(` ${fs.readFileSync(file, 'utf-8').trim()}`);
|
||||||
|
} else {
|
||||||
|
console.log(` (none stored at ${file})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFile(file: string): boolean {
|
||||||
|
return fs.statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a replacement for a vault key and stores it beside the original.
|
||||||
|
*
|
||||||
|
* Nothing is deleted or overwritten; `retireKey` is the other half.
|
||||||
|
*/
|
||||||
|
export async function rotateKey(
|
||||||
|
sshDir: string,
|
||||||
|
keysDir: string,
|
||||||
|
tmpDir: string,
|
||||||
|
pubkey: string
|
||||||
|
): Promise<void> {
|
||||||
|
const vaultKeys = listVaultKeys(keysDir);
|
||||||
|
|
||||||
|
if (vaultKeys.length === 0) {
|
||||||
|
console.log('⚠️ No encrypted keys to rotate — generate or encrypt one first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = await inquirer.prompt<{ key: string }>([
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
name: 'key',
|
||||||
|
message: 'Select the key to rotate:',
|
||||||
|
choices: vaultKeys,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentPublicKey = path.join(keysDir, key, `id_${key}.pub`);
|
||||||
|
const { algorithm, identity } = await promptKeyOptions(storedComment(currentPublicKey));
|
||||||
|
|
||||||
|
const replacement = nextRotationName(key, [
|
||||||
|
...vaultKeys,
|
||||||
|
...plaintextNames(tmpDir),
|
||||||
|
...plaintextNames(sshDir),
|
||||||
|
]);
|
||||||
|
const keyPath = path.join(tmpDir, `id_${replacement}`);
|
||||||
|
|
||||||
|
console.log(`\n🔄 Rotating ${key} → ${replacement}`);
|
||||||
|
console.log(` ${key} is left exactly as it is, in the vault and on its hosts.\n`);
|
||||||
|
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
|
||||||
|
if (!(await createKeyPair(keyPath, algorithm, identity))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storeInVault(keyPath, keysDir, pubkey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ Error encrypting the replacement: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
console.error(` ${keyPath} was generated; encrypt it once the problem is fixed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPublicKey(`Current — still valid (${key}):`, currentPublicKey);
|
||||||
|
showPublicKey(`Replacement — deploy this (${replacement}):`, `${keyPath}.pub`);
|
||||||
|
|
||||||
|
console.log('\n Next:');
|
||||||
|
console.log(` 1. Add the replacement public key wherever ${key} is authorized.`);
|
||||||
|
console.log(` 2. Check that you can log in with ${keyPath}.`);
|
||||||
|
console.log(` 3. Remove ${key} from those hosts, then retire it here.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a vault key and its plaintext copies, after saying exactly what goes.
|
||||||
|
*
|
||||||
|
* The second half of a rotation, and the only operation in keyman that destroys an
|
||||||
|
* encrypted key.
|
||||||
|
*/
|
||||||
|
export async function retireKey(sshDir: string, keysDir: string, tmpDir: string): Promise<void> {
|
||||||
|
const vaultKeys = listVaultKeys(keysDir);
|
||||||
|
|
||||||
|
if (vaultKeys.length === 0) {
|
||||||
|
console.log('⚠️ No encrypted keys in the vault.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = await inquirer.prompt<{ key: string }>([
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
name: 'key',
|
||||||
|
message: 'Select the key to retire:',
|
||||||
|
choices: vaultKeys,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const vaultPath = path.join(keysDir, key);
|
||||||
|
const files = [
|
||||||
|
...fs.readdirSync(vaultPath).map((file) => path.join(vaultPath, file)),
|
||||||
|
path.join(tmpDir, `id_${key}`),
|
||||||
|
path.join(tmpDir, `id_${key}.pub`),
|
||||||
|
path.join(sshDir, `id_${key}`),
|
||||||
|
path.join(sshDir, `id_${key}.pub`),
|
||||||
|
].filter(isFile);
|
||||||
|
|
||||||
|
const successor = supersededBy(key, vaultKeys);
|
||||||
|
|
||||||
|
console.log(`\n🗑️ Retiring ${key} deletes:`);
|
||||||
|
for (const file of files) {
|
||||||
|
console.log(` ${file}`);
|
||||||
|
}
|
||||||
|
if (successor) {
|
||||||
|
console.log(`\n ${successor} is in the vault and supersedes ${key}.`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n⚠️ Nothing in the vault supersedes ${key}: this deletes the only copy.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'confirmed',
|
||||||
|
message: `Delete ${files.length} ${files.length === 1 ? 'file' : 'files'}?`,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log(' Nothing was deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed out when there is no successor, because that is the deletion this tool
|
||||||
|
// exists to prevent: an encrypted key nothing replaces is the only copy there is,
|
||||||
|
// and a y/n is one keystroke away from an irreversible one.
|
||||||
|
if (!successor) {
|
||||||
|
const { typed } = await inquirer.prompt<{ typed: string }>([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'typed',
|
||||||
|
message: `Type ${key} to confirm:`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (typed.trim() !== key) {
|
||||||
|
console.log(' Name did not match — nothing was deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
console.log(` Removed ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only while empty: anything left in there was not ours to delete.
|
||||||
|
fs.rmdirSync(vaultPath);
|
||||||
|
} catch {
|
||||||
|
console.log(` Kept ${vaultPath} — it still holds other files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Retired ${key}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
/**
|
||||||
|
* Update checking and self-update for the keyman CLI
|
||||||
|
*
|
||||||
|
* A near-copy of nopy's `nopy.update` module, differing only in the package it
|
||||||
|
* names and the environment variables it reads. The two CLIs share no internal
|
||||||
|
* library — keyman deliberately stands alone — and a fifth workspace package
|
||||||
|
* for ~250 lines would buy another edge in the publish order for nothing. If a
|
||||||
|
* third CLI ever appears, extract it then.
|
||||||
|
*
|
||||||
|
* @module keyman.update
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execa } from 'execa';
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
|
/** The published package this CLI ships as */
|
||||||
|
export const PACKAGE_NAME = '@bitsquare/keyman';
|
||||||
|
|
||||||
|
/** The npm scope the package lives under, used for the registry config key */
|
||||||
|
export const SCOPE = '@bitsquare';
|
||||||
|
|
||||||
|
/** Where packages resolve from when nothing says otherwise */
|
||||||
|
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
|
||||||
|
|
||||||
|
/** Directory under the user's home holding the update-check cache */
|
||||||
|
export const UPDATE_CACHE_DIR = '.keyman';
|
||||||
|
|
||||||
|
/** File name of the update-check cache */
|
||||||
|
export const UPDATE_CACHE_FILE = 'update-check.json';
|
||||||
|
|
||||||
|
/** How long a cached check is considered fresh */
|
||||||
|
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** How long the background check may block the CLI */
|
||||||
|
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
/** How long `npm config get` may take before the registry falls back to npmjs */
|
||||||
|
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dist-tag this project publishes under.
|
||||||
|
*
|
||||||
|
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
|
||||||
|
* built from a commit on `main` and published to Gitea only.
|
||||||
|
*/
|
||||||
|
export type Channel = 'latest' | 'next' | 'main';
|
||||||
|
|
||||||
|
/** A package manager that can install a global binary */
|
||||||
|
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||||
|
|
||||||
|
/** Runs a command and resolves with its trimmed stdout */
|
||||||
|
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
|
||||||
|
|
||||||
|
/** The result of an update check */
|
||||||
|
export interface UpdateStatus {
|
||||||
|
/** The version currently running */
|
||||||
|
current: string;
|
||||||
|
/** The version the channel points at, or null if it could not be determined */
|
||||||
|
latest: string | null;
|
||||||
|
/** The channel the current version implies */
|
||||||
|
channel: Channel;
|
||||||
|
/** The registry the check went to */
|
||||||
|
registry: string;
|
||||||
|
/** Whether `latest` is strictly newer than `current` */
|
||||||
|
updateAvailable: boolean;
|
||||||
|
/** Whether the answer came from cache rather than the network */
|
||||||
|
fromCache: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The on-disk update-check cache */
|
||||||
|
export interface UpdateCache {
|
||||||
|
/** ISO timestamp of the check */
|
||||||
|
checkedAt: string;
|
||||||
|
/** The channel that was checked */
|
||||||
|
channel: Channel;
|
||||||
|
/** The registry that was checked */
|
||||||
|
registry: string;
|
||||||
|
/** The version the channel pointed at, or null if the lookup found nothing */
|
||||||
|
latest: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the release channel from a version string.
|
||||||
|
*
|
||||||
|
* @param version - a semver version, typically this package's own
|
||||||
|
* @returns the dist-tag that version would have been published under
|
||||||
|
*/
|
||||||
|
export function channelForVersion(version: string): Channel {
|
||||||
|
const parsed = semver.parse(version, { loose: true });
|
||||||
|
|
||||||
|
if (!parsed || parsed.prerelease.length === 0) {
|
||||||
|
return 'latest';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalises a registry URL to the trailing-slash form the packument path is appended to */
|
||||||
|
export function normalizeRegistry(url: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a command through execa and returns its stdout */
|
||||||
|
const defaultRunner: CommandRunner = async (file, args) => {
|
||||||
|
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
|
||||||
|
return stdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the registry `@bitsquare` packages come from.
|
||||||
|
*
|
||||||
|
* `KEYMAN_REGISTRY` wins, then npm's own scoped-registry config, then npmjs.
|
||||||
|
*/
|
||||||
|
export async function resolveRegistry(
|
||||||
|
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
|
||||||
|
): Promise<string> {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
|
||||||
|
const override = env.KEYMAN_REGISTRY?.trim();
|
||||||
|
if (override) {
|
||||||
|
return normalizeRegistry(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = options.run ?? defaultRunner;
|
||||||
|
try {
|
||||||
|
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
|
||||||
|
// npm prints the string "undefined" for an unset key rather than nothing.
|
||||||
|
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
|
||||||
|
return normalizeRegistry(stdout);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// npm not on PATH, or the config is unreadable.
|
||||||
|
}
|
||||||
|
|
||||||
|
return NPMJS_REGISTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the version a dist-tag points at, straight from the registry.
|
||||||
|
*
|
||||||
|
* @returns the version, or null if the registry or the tag has nothing
|
||||||
|
*/
|
||||||
|
export async function fetchChannelVersion(options: {
|
||||||
|
registry: string;
|
||||||
|
channel: Channel;
|
||||||
|
packageName?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
token?: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
||||||
|
const packageName = options.packageName ?? PACKAGE_NAME;
|
||||||
|
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/vnd.npm.install-v1+json, application/json',
|
||||||
|
};
|
||||||
|
if (options.token) {
|
||||||
|
headers.authorization = `Bearer ${options.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await doFetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
|
||||||
|
return body['dist-tags']?.[options.channel] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path of the update-check cache file */
|
||||||
|
export function getUpdateCachePath(homedir: string = os.homedir()): string {
|
||||||
|
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the update-check cache.
|
||||||
|
*
|
||||||
|
* @returns the cache, or null if it is missing or unreadable
|
||||||
|
*/
|
||||||
|
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
|
||||||
|
return typeof parsed?.checkedAt === 'string' ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the update-check cache. Best effort — a read-only home costs a check, not a failure */
|
||||||
|
export function writeUpdateCache(
|
||||||
|
cache: UpdateCache,
|
||||||
|
cachePath: string = getUpdateCachePath()
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
|
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// Ignored on purpose.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the startup check should be skipped entirely */
|
||||||
|
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
const flag = env.KEYMAN_NO_UPDATE_CHECK?.trim().toLowerCase();
|
||||||
|
if (flag && flag !== '0' && flag !== 'false') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(env.CI);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a newer version exists on the current channel.
|
||||||
|
*
|
||||||
|
* Answers from cache when a check happened recently for the same channel and
|
||||||
|
* registry; a failed lookup degrades to the cached answer rather than none.
|
||||||
|
*/
|
||||||
|
export async function checkForUpdate(options: {
|
||||||
|
currentVersion: string;
|
||||||
|
channel?: Channel;
|
||||||
|
registry?: string;
|
||||||
|
force?: boolean;
|
||||||
|
intervalMs?: number;
|
||||||
|
cachePath?: string;
|
||||||
|
now?: number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
timeoutMs?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
run?: CommandRunner;
|
||||||
|
}): Promise<UpdateStatus> {
|
||||||
|
const {
|
||||||
|
currentVersion,
|
||||||
|
force = false,
|
||||||
|
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
|
||||||
|
cachePath = getUpdateCachePath(),
|
||||||
|
now = Date.now(),
|
||||||
|
env = process.env,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const channel = options.channel ?? channelForVersion(currentVersion);
|
||||||
|
const registry = normalizeRegistry(
|
||||||
|
options.registry ?? (await resolveRegistry({ env, run: options.run }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const cache = readUpdateCache(cachePath);
|
||||||
|
const applicable = cache && cache.channel === channel && cache.registry === registry;
|
||||||
|
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
|
||||||
|
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
|
||||||
|
|
||||||
|
if (!force && fresh && cache) {
|
||||||
|
return status(currentVersion, cache.latest, channel, registry, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latest = await fetchChannelVersion({
|
||||||
|
registry,
|
||||||
|
channel,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
token: env.KEYMAN_REGISTRY_TOKEN?.trim() || undefined,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
});
|
||||||
|
writeUpdateCache(
|
||||||
|
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
return status(currentVersion, latest, channel, registry, false);
|
||||||
|
} catch {
|
||||||
|
return status(
|
||||||
|
currentVersion,
|
||||||
|
applicable && cache ? cache.latest : null,
|
||||||
|
channel,
|
||||||
|
registry,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
|
||||||
|
function status(
|
||||||
|
current: string,
|
||||||
|
latest: string | null,
|
||||||
|
channel: Channel,
|
||||||
|
registry: string,
|
||||||
|
fromCache: boolean
|
||||||
|
): UpdateStatus {
|
||||||
|
const updateAvailable = Boolean(
|
||||||
|
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
|
||||||
|
);
|
||||||
|
return { current, latest, channel, registry, updateAvailable, fromCache };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects which package manager installed this CLI, so `self-update` re-runs
|
||||||
|
* the same one rather than leaving two copies on the PATH.
|
||||||
|
*/
|
||||||
|
export function detectPackageManager(
|
||||||
|
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
|
||||||
|
): PackageManager {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
|
||||||
|
const override = env.KEYMAN_PACKAGE_MANAGER?.trim().toLowerCase();
|
||||||
|
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
|
||||||
|
if (from.includes('/pnpm/')) return 'pnpm';
|
||||||
|
if (from.includes('/.bun/')) return 'bun';
|
||||||
|
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
|
||||||
|
return 'npm';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the command that installs a given channel globally.
|
||||||
|
*
|
||||||
|
* The registry is passed as a **scoped** override rather than `--registry`,
|
||||||
|
* because the Gitea registry serves `@bitsquare` packages and does not proxy
|
||||||
|
* npmjs — a global `--registry` would send every dependency to a registry that
|
||||||
|
* has never heard of them.
|
||||||
|
*/
|
||||||
|
export function buildSelfUpdateCommand(options: {
|
||||||
|
packageManager: PackageManager;
|
||||||
|
channel: Channel;
|
||||||
|
registry: string;
|
||||||
|
packageName?: string;
|
||||||
|
}): { file: string; args: string[] } {
|
||||||
|
const packageName = options.packageName ?? PACKAGE_NAME;
|
||||||
|
const spec = `${packageName}@${options.channel}`;
|
||||||
|
|
||||||
|
const registryArgs =
|
||||||
|
normalizeRegistry(options.registry) === NPMJS_REGISTRY
|
||||||
|
? []
|
||||||
|
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
|
||||||
|
|
||||||
|
switch (options.packageManager) {
|
||||||
|
case 'pnpm':
|
||||||
|
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
|
||||||
|
case 'yarn':
|
||||||
|
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
|
||||||
|
case 'bun':
|
||||||
|
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
|
||||||
|
default:
|
||||||
|
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a command as the shell line a user could paste */
|
||||||
|
export function formatCommand(command: { file: string; args: string[] }): string {
|
||||||
|
return [command.file, ...command.args].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the hint printed at startup when an update exists.
|
||||||
|
*
|
||||||
|
* @returns the notice, or null when there is nothing to say
|
||||||
|
*/
|
||||||
|
export function formatUpdateNotice(
|
||||||
|
status: UpdateStatus,
|
||||||
|
packageManager?: PackageManager
|
||||||
|
): string | null {
|
||||||
|
if (!status.updateAvailable || !status.latest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = buildSelfUpdateCommand({
|
||||||
|
packageManager: packageManager ?? detectPackageManager(),
|
||||||
|
channel: status.channel,
|
||||||
|
registry: status.registry,
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
|
||||||
|
return [
|
||||||
|
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
|
||||||
|
`Run "keyman self-update" or "${formatCommand(command)}"`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The startup path: returns the notice to print, or null.
|
||||||
|
*
|
||||||
|
* Never throws and never blocks for longer than the fetch timeout.
|
||||||
|
*/
|
||||||
|
export async function updateNotice(options: {
|
||||||
|
currentVersion: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
cachePath?: string;
|
||||||
|
intervalMs?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
now?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
run?: CommandRunner;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
if (isUpdateCheckDisabled(env)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await checkForUpdate({ ...options, env });
|
||||||
|
return formatUpdateNotice(status, detectPackageManager({ env }));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of a {@link selfUpdate} run */
|
||||||
|
export interface SelfUpdateResult {
|
||||||
|
/** The status the decision was based on */
|
||||||
|
status: UpdateStatus;
|
||||||
|
/** The command that was run, or would have been run */
|
||||||
|
command: { file: string; args: string[] };
|
||||||
|
/** Whether the install actually ran */
|
||||||
|
ran: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the newest version on the current channel.
|
||||||
|
*
|
||||||
|
* @param options.dryRun - print the command instead of running it
|
||||||
|
* @param options.force - reinstall even when already up to date
|
||||||
|
*/
|
||||||
|
export async function selfUpdate(options: {
|
||||||
|
currentVersion: string;
|
||||||
|
channel?: Channel;
|
||||||
|
registry?: string;
|
||||||
|
packageManager?: PackageManager;
|
||||||
|
dryRun?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
cachePath?: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
run?: CommandRunner;
|
||||||
|
spawn?: (file: string, args: string[]) => Promise<unknown>;
|
||||||
|
}): Promise<SelfUpdateResult> {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
|
||||||
|
// Always ignore the cache here: the user asked, so the answer has to be current.
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
channel: options.channel,
|
||||||
|
registry: options.registry,
|
||||||
|
force: true,
|
||||||
|
cachePath: options.cachePath,
|
||||||
|
env,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
run: options.run,
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = buildSelfUpdateCommand({
|
||||||
|
packageManager: options.packageManager ?? detectPackageManager({ env }),
|
||||||
|
channel: status.channel,
|
||||||
|
registry: status.registry,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.dryRun || (!status.updateAvailable && !options.force)) {
|
||||||
|
return { status, command, ran: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawn =
|
||||||
|
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
|
||||||
|
await spawn(command.file, command.args);
|
||||||
|
|
||||||
|
return { status, command, ran: true };
|
||||||
|
}
|
||||||
@@ -1,16 +1,91 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { execa, type Options } from 'execa';
|
||||||
|
|
||||||
|
/** A binary keyman needs is not installed — recoverable, unlike a tool refusing */
|
||||||
|
export class ToolNotFoundError extends Error {
|
||||||
|
constructor(readonly binary: string) {
|
||||||
|
super(`\`${binary}\` was not found on PATH. Install it and try again.`);
|
||||||
|
this.name = 'ToolNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the public key from an age key file.
|
* Runs one of the external binaries keyman depends on.
|
||||||
* @param keyFilePath Path to the age key file.
|
*
|
||||||
* @returns The public key as a string, or null if not found.
|
* Two failures are worth telling apart, and an execa error tells a reader
|
||||||
|
* neither: the binary not being installed (`ENOENT`, whose message is
|
||||||
|
* `spawn <name> ENOENT`) and the binary refusing (whose reason is on stderr and
|
||||||
|
* nowhere in the thrown message). `age` is a hard requirement, so its absence
|
||||||
|
* has to read as an instruction.
|
||||||
|
*
|
||||||
|
* Returns only `stdout` — annotated rather than inferred because execa's result
|
||||||
|
* type cannot be named from here (TS2883), and it is all any caller wants. Empty
|
||||||
|
* when the output went somewhere else, as with `stdio: 'inherit'`.
|
||||||
*/
|
*/
|
||||||
export function extractAgePublicKey(keyFilePath: string): string | null {
|
export async function runTool(
|
||||||
|
binary: string,
|
||||||
|
args: string[],
|
||||||
|
options?: Options
|
||||||
|
): Promise<{ stdout: string }> {
|
||||||
|
try {
|
||||||
|
// Called without the third argument when there are no options, so a test
|
||||||
|
// asserting on the spawn sees the call it wrote.
|
||||||
|
const result = options ? await execa(binary, args, options) : await execa(binary, args);
|
||||||
|
return { stdout: typeof result.stdout === 'string' ? result.stdout : '' };
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as { code?: string; stderr?: string; shortMessage?: string };
|
||||||
|
if (failure.code === 'ENOENT') {
|
||||||
|
throw new ToolNotFoundError(binary);
|
||||||
|
}
|
||||||
|
throw new Error(`\`${binary}\` failed: ${failure.stderr?.trim() || failure.shortMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The age recipient a vault encrypts to, derived from its identity file.
|
||||||
|
*
|
||||||
|
* `age-keygen -y` derives the public key from the secret key, so it cannot
|
||||||
|
* disagree with it. The `# public key:` comment can: it is ordinary text that
|
||||||
|
* nothing re-checks, and a wrong one encrypts the vault to a recipient nobody
|
||||||
|
* holds the private half of. Verified — rewriting the comment does not change
|
||||||
|
* what `-y` reports.
|
||||||
|
*
|
||||||
|
* The comment stays as a fallback for a machine with no `age-keygen`, behind a
|
||||||
|
* warning that it is unverified. It is *not* a fallback for `age-keygen`
|
||||||
|
* refusing the file: that means age cannot read the identity, and trusting the
|
||||||
|
* comment then would encrypt to a recipient the vault could never decrypt with.
|
||||||
|
*
|
||||||
|
* @returns the recipient, or null with the reason already reported
|
||||||
|
*/
|
||||||
|
export async function extractAgePublicKey(keyFilePath: string): Promise<string | null> {
|
||||||
if (!fs.existsSync(keyFilePath)) {
|
if (!fs.existsSync(keyFilePath)) {
|
||||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await runTool('age-keygen', ['-y', keyFilePath]);
|
||||||
|
const derived = stdout.trim();
|
||||||
|
if (derived.startsWith('age1')) {
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
|
console.error(`❌ ERROR: age-keygen derived no public key from ${keyFilePath}`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ToolNotFoundError)) {
|
||||||
|
console.error(`❌ ERROR: ${error instanceof Error ? error.message : error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`⚠️ age-keygen is not installed — reading the public key from the comment in ${keyFilePath}, unverified against the secret key.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKeyFromComment(keyFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `# public key:` line: a claim about the key rather than a derivation from it */
|
||||||
|
function publicKeyFromComment(keyFilePath: string): string | null {
|
||||||
try {
|
try {
|
||||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { runTool } from './keyman.utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vault entries that hold an encrypted key, sorted.
|
||||||
|
*
|
||||||
|
* A directory counts as an entry when it holds `id_<dir>.age` — the layout
|
||||||
|
* `storeInVault` writes and `decrypt` reads back — which is what keeps a stray
|
||||||
|
* file, or a directory whose encryption failed, out of every menu built from this.
|
||||||
|
* Sorted because the order otherwise comes from the filesystem.
|
||||||
|
*/
|
||||||
|
export function listVaultKeys(keysDir: string): string[] {
|
||||||
|
// Nothing creates the keys directory until the first encrypt, so on a fresh
|
||||||
|
// vault this readdir threw instead of reporting an empty one.
|
||||||
|
if (!fs.existsSync(keysDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(keysDir)
|
||||||
|
.filter((key) => fs.existsSync(path.join(keysDir, key, `id_${key}.age`)))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public half of a private key, derived if the sibling file is missing.
|
||||||
|
*
|
||||||
|
* `encrypt` builds its selection list from private keys only, so a key whose
|
||||||
|
* `.pub` was deleted is offered like any other. Reading the sibling blindly meant
|
||||||
|
* finding out it was absent *after* `age` had written the encrypted key — a vault
|
||||||
|
* entry with no public key, and an exception that killed the rest of the batch.
|
||||||
|
*
|
||||||
|
* @returns the public key text, or null with the reason already reported
|
||||||
|
*/
|
||||||
|
async function publicKeyFor(keyPath: string): Promise<string | null> {
|
||||||
|
const sibling = `${keyPath}.pub`;
|
||||||
|
if (fs.existsSync(sibling)) {
|
||||||
|
return fs.readFileSync(sibling, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(keyPath);
|
||||||
|
console.log(`ℹ️ ${fileName} has no .pub file — deriving it with ssh-keygen.`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// stdin and stderr inherited, stdout piped: verified that `ssh-keygen -y`
|
||||||
|
// prompts for the passphrase of an encrypted key, and that it prompts on
|
||||||
|
// *stderr*. Capturing everything would hide the prompt and then fail on the
|
||||||
|
// passphrase nobody was asked for; inheriting everything would lose the key.
|
||||||
|
const { stdout } = await runTool('ssh-keygen', ['-y', '-f', keyPath], {
|
||||||
|
stdio: ['inherit', 'pipe', 'inherit'],
|
||||||
|
});
|
||||||
|
const derived = stdout.trim();
|
||||||
|
if (derived) {
|
||||||
|
return `${derived}\n`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ ${fileName}: ${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`⚠️ ${fileName}: no public key could be derived; storing the private key alone.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts one private key into `<keysDir>/<name>/`, alongside its public half.
|
||||||
|
*
|
||||||
|
* Shared by `encrypt` and `generate`, which were two copies of it.
|
||||||
|
*
|
||||||
|
* @returns the vault directory the key was stored in
|
||||||
|
*/
|
||||||
|
export async function storeInVault(
|
||||||
|
keyPath: string,
|
||||||
|
keysDir: string,
|
||||||
|
pubkey: string
|
||||||
|
): Promise<string> {
|
||||||
|
const fileName = path.basename(keyPath);
|
||||||
|
const vaultPath = path.join(keysDir, fileName.replace(/^id_/, ''));
|
||||||
|
|
||||||
|
// Before the directory exists, so a key that cannot be read does not leave one.
|
||||||
|
const publicKey = await publicKeyFor(keyPath);
|
||||||
|
|
||||||
|
fs.mkdirSync(vaultPath, { recursive: true, mode: 0o700 });
|
||||||
|
const encryptedKey = path.join(vaultPath, `${fileName}.age`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTool('age', ['-r', pubkey, '-o', encryptedKey, keyPath]);
|
||||||
|
} catch (error) {
|
||||||
|
// age writes into a directory that has to exist already, so a failure here
|
||||||
|
// leaves one behind — and possibly a truncated .age file, which `list` would
|
||||||
|
// count as a vault entry and `decrypt` would offer. Both are ours: the file
|
||||||
|
// because we named it, the directory only while it is empty, since one
|
||||||
|
// holding an earlier key is not.
|
||||||
|
fs.rmSync(encryptedKey, { force: true });
|
||||||
|
try {
|
||||||
|
fs.rmdirSync(vaultPath);
|
||||||
|
} catch {
|
||||||
|
// ENOTEMPTY — something else was already stored here.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey !== null) {
|
||||||
|
fs.writeFileSync(path.join(vaultPath, `${fileName}.pub`), publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔒 Encrypted and stored: ${path.join(vaultPath, `${fileName}.age`)}`);
|
||||||
|
return vaultPath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Tests for keyman's argv parsing.
|
||||||
|
*
|
||||||
|
* The old inline reader in keyman.cli.ts turned three different mistakes into
|
||||||
|
* silence or into a wrong diagnosis, so the interesting cases here are the
|
||||||
|
* rejections rather than the happy paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CHANNELS, helpText, KNOWN_FLAGS, parseArgs, UsageError } from '../src/keyman.args.js';
|
||||||
|
|
||||||
|
describe('parseArgs', () => {
|
||||||
|
it('defaults to the interactive session', () => {
|
||||||
|
expect(parseArgs([])).toEqual({ command: 'interactive' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[['--help'], 'help'],
|
||||||
|
[['-h'], 'help'],
|
||||||
|
[['--version'], 'version'],
|
||||||
|
[['-V'], 'version'],
|
||||||
|
[['--print-config'], 'print-config'],
|
||||||
|
] as const)('%s selects %s', (argv, command) => {
|
||||||
|
expect(parseArgs([...argv])).toEqual({ command });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers --help even when the rest of the line is wrong', () => {
|
||||||
|
expect(parseArgs(['--bogus', '--help'])).toEqual({ command: 'help' });
|
||||||
|
expect(parseArgs(['--help', '--channel'])).toEqual({ command: 'help' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('self-update', () => {
|
||||||
|
it.each(['self-update', 'upgrade'])('is selected by the %s subcommand', (subcommand) => {
|
||||||
|
expect(parseArgs([subcommand])).toEqual({
|
||||||
|
command: 'self-update',
|
||||||
|
dryRun: false,
|
||||||
|
force: false,
|
||||||
|
channel: undefined,
|
||||||
|
registry: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is selected by --self-update', () => {
|
||||||
|
expect(parseArgs(['--self-update'])).toMatchObject({ command: 'self-update' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects its flags, long and short', () => {
|
||||||
|
expect(parseArgs(['self-update', '--dry-run', '--force'])).toMatchObject({
|
||||||
|
dryRun: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
expect(parseArgs(['self-update', '-n', '-f'])).toMatchObject({
|
||||||
|
dryRun: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['--channel main', '--channel=main'])('accepts %s', (form) => {
|
||||||
|
expect(parseArgs(['self-update', ...form.split(' ')])).toMatchObject({ channel: 'main' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts every real channel', () => {
|
||||||
|
for (const channel of CHANNELS) {
|
||||||
|
expect(parseArgs(['self-update', '--channel', channel])).toMatchObject({ channel });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a registry in either form', () => {
|
||||||
|
expect(parseArgs(['self-update', '--registry', 'https://r.example'])).toMatchObject({
|
||||||
|
registry: 'https://r.example',
|
||||||
|
});
|
||||||
|
expect(parseArgs(['self-update', '--registry=https://r.example'])).toMatchObject({
|
||||||
|
registry: 'https://r.example',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a value that starts with a dash when it was written inline', () => {
|
||||||
|
expect(parseArgs(['self-update', '--registry=-weird'])).toMatchObject({
|
||||||
|
registry: '-weird',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rejections', () => {
|
||||||
|
const reject = (argv: string[]) => () => parseArgs(argv);
|
||||||
|
|
||||||
|
it('rejects a channel that is not a channel', () => {
|
||||||
|
expect(reject(['self-update', '--channel', 'stable'])).toThrow(UsageError);
|
||||||
|
expect(reject(['self-update', '--channel', 'stable'])).toThrow(
|
||||||
|
'Unknown channel: stable (expected latest, next, main)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the next flag being eaten as a value', () => {
|
||||||
|
// The bug this whole module exists for: --channel --force used to set the
|
||||||
|
// channel to "--force" and report an unreachable registry.
|
||||||
|
expect(reject(['self-update', '--channel', '--force'])).toThrow('--channel expects a value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a value flag with nothing after it', () => {
|
||||||
|
expect(reject(['self-update', '--channel'])).toThrow('--channel expects a value');
|
||||||
|
expect(reject(['self-update', '--registry='])).toThrow('--registry expects a value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a boolean flag given a value', () => {
|
||||||
|
expect(reject(['--dry-run=yes'])).toThrow('--dry-run does not take a value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown flags and commands', () => {
|
||||||
|
expect(reject(['--vault', 'foo'])).toThrow('Unknown flag: --vault');
|
||||||
|
expect(reject(['-x'])).toThrow('Unknown flag: -x');
|
||||||
|
expect(reject(['encrypt'])).toThrow('Unknown command: encrypt');
|
||||||
|
expect(reject(['self-update', 'upgrade'])).toThrow('Unexpected argument: upgrade');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['--channel', '--registry', '--dry-run', '-n', '--force', '-f'])(
|
||||||
|
'rejects %s without self-update rather than ignoring it',
|
||||||
|
(flag) => {
|
||||||
|
const argv = flag === '--channel' || flag === '--registry' ? [flag, 'main'] : [flag];
|
||||||
|
expect(reject(argv)).toThrow('is only valid with `keyman self-update`');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects a self-update flag alongside another command', () => {
|
||||||
|
expect(reject(['--print-config', '--force'])).toThrow('--force is only valid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('helpText', () => {
|
||||||
|
it('documents every flag the parser accepts', () => {
|
||||||
|
const text = helpText();
|
||||||
|
for (const flag of KNOWN_FLAGS) {
|
||||||
|
expect(text, `${flag} is missing from --help`).toContain(flag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names both subcommands, every channel, and the environment variables', () => {
|
||||||
|
const text = helpText();
|
||||||
|
expect(text).toContain('self-update');
|
||||||
|
expect(text).toContain('upgrade');
|
||||||
|
for (const channel of CHANNELS) {
|
||||||
|
expect(text).toContain(channel);
|
||||||
|
}
|
||||||
|
for (const variable of [
|
||||||
|
'VAULT_ROOT',
|
||||||
|
'KEYMAN_REGISTRY',
|
||||||
|
'KEYMAN_REGISTRY_TOKEN',
|
||||||
|
'KEYMAN_NO_UPDATE_CHECK',
|
||||||
|
'KEYMAN_PACKAGE_MANAGER',
|
||||||
|
]) {
|
||||||
|
expect(text).toContain(variable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the plaintext hygiene helpers: the vault .gitignore and the
|
||||||
|
* clear-decrypted-keys operation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { prompt } = vi.hoisted(() => ({ prompt: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||||
|
|
||||||
|
import { clearDecryptedKeys, writeVaultGitignore } from '../src/keyman.clear.js';
|
||||||
|
|
||||||
|
describe('writeVaultGitignore', () => {
|
||||||
|
let vaultRoot: string;
|
||||||
|
|
||||||
|
const read = () => fs.readFileSync(path.join(vaultRoot, '.gitignore'), 'utf-8');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-ignore-')));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(vaultRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the identity and the decrypted keys, not the encrypted ones', () => {
|
||||||
|
writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key'));
|
||||||
|
|
||||||
|
const contents = read();
|
||||||
|
expect(contents).toContain('age.key\n');
|
||||||
|
expect(contents).toContain('age.key.pub');
|
||||||
|
expect(contents).toContain('tmp/');
|
||||||
|
// The encrypted keys are the thing worth committing.
|
||||||
|
expect(contents).not.toContain('keys/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured names', () => {
|
||||||
|
writeVaultGitignore(
|
||||||
|
vaultRoot,
|
||||||
|
path.join(vaultRoot, 'plain'),
|
||||||
|
path.join(vaultRoot, 'identity.age')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(read()).toContain('plain/');
|
||||||
|
expect(read()).toContain('identity.age');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about a directory outside the vault', () => {
|
||||||
|
writeVaultGitignore(vaultRoot, '/elsewhere/tmp', path.join(vaultRoot, 'age.key'));
|
||||||
|
|
||||||
|
// A .gitignore cannot speak for a path above itself, and pretending otherwise
|
||||||
|
// would read as protection that is not there.
|
||||||
|
expect(read()).not.toContain('elsewhere');
|
||||||
|
expect(read()).toContain('age.key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never overwrites an existing file', () => {
|
||||||
|
fs.writeFileSync(path.join(vaultRoot, '.gitignore'), 'mine\n');
|
||||||
|
|
||||||
|
writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key'));
|
||||||
|
|
||||||
|
expect(read()).toBe('mine\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates it private to the owner', () => {
|
||||||
|
writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key'));
|
||||||
|
|
||||||
|
expect(fs.statSync(path.join(vaultRoot, '.gitignore')).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearDecryptedKeys', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
const decrypted = (name: string) => {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, name), 'PRIVATE');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, `${name}.pub`), 'PUBLIC');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-clear-')));
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
prompt.mockResolvedValue({ confirmed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when there is nothing to clear', async () => {
|
||||||
|
await clearDecryptedKeys(tmpDir);
|
||||||
|
|
||||||
|
expect(messages()).toContain('Nothing decrypted');
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mind a tmp directory that was never created', async () => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
await expect(clearDecryptedKeys(tmpDir)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes each key and its public half', async () => {
|
||||||
|
decrypted('id_prod');
|
||||||
|
decrypted('id_stage');
|
||||||
|
|
||||||
|
await clearDecryptedKeys(tmpDir);
|
||||||
|
|
||||||
|
expect(fs.readdirSync(tmpDir)).toEqual([]);
|
||||||
|
expect(messages()).toContain('Removed id_prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists what it is about to delete before asking', async () => {
|
||||||
|
decrypted('id_prod');
|
||||||
|
|
||||||
|
await clearDecryptedKeys(tmpDir);
|
||||||
|
|
||||||
|
const askedAt = messages().indexOf('id_prod');
|
||||||
|
expect(askedAt).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(prompt.mock.calls[0][0][0]).toMatchObject({ type: 'confirm', default: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps everything when the confirmation is declined', async () => {
|
||||||
|
decrypted('id_prod');
|
||||||
|
prompt.mockResolvedValue({ confirmed: false });
|
||||||
|
|
||||||
|
await clearDecryptedKeys(tmpDir);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true);
|
||||||
|
expect(messages()).toContain('Nothing was deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves files that are not keys alone', async () => {
|
||||||
|
decrypted('id_prod');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'notes.md'), 'mine');
|
||||||
|
|
||||||
|
await clearDecryptedKeys(tmpDir);
|
||||||
|
|
||||||
|
expect(fs.readdirSync(tmpDir)).toEqual(['notes.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fail on a key whose public half is missing', async () => {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'id_prod'), 'PRIVATE');
|
||||||
|
|
||||||
|
await clearDecryptedKeys(tmpDir);
|
||||||
|
|
||||||
|
expect(fs.readdirSync(tmpDir)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the clipboard layer.
|
||||||
|
*
|
||||||
|
* The platform is passed in rather than stubbed, so every branch is reachable from
|
||||||
|
* the one machine the suite runs on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { execa } = vi.hoisted(() => ({ execa: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('execa', () => ({ execa }));
|
||||||
|
|
||||||
|
import { clipboardTools, copyToClipboard } from '../src/keyman.clipboard.js';
|
||||||
|
|
||||||
|
describe('clipboardTools', () => {
|
||||||
|
it.each([
|
||||||
|
['darwin', ['pbcopy']],
|
||||||
|
['win32', ['clip']],
|
||||||
|
['linux', ['wl-copy', 'xclip', 'xsel']],
|
||||||
|
// Anything unrecognised gets the X11/Wayland list rather than nothing: a BSD
|
||||||
|
// running the same desktop stack is closer to linux than to no answer.
|
||||||
|
['freebsd', ['wl-copy', 'xclip', 'xsel']],
|
||||||
|
])('offers the right commands on %s', (platform, expected) => {
|
||||||
|
expect(clipboardTools(platform).map((t) => t.binary)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the clipboard selection to the X11 tools', () => {
|
||||||
|
const byBinary = new Map(clipboardTools('linux').map((t) => [t.binary, t.args]));
|
||||||
|
|
||||||
|
// Without these, xclip and xsel write to the primary selection, which is not
|
||||||
|
// the clipboard a paste reads from.
|
||||||
|
expect(byBinary.get('xclip')).toEqual(['-selection', 'clipboard']);
|
||||||
|
expect(byBinary.get('xsel')).toEqual(['--clipboard', '--input']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyToClipboard', () => {
|
||||||
|
const notFound = () =>
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
execa.mockResolvedValue({ stdout: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pipes the text to the first available command', async () => {
|
||||||
|
const tool = await copyToClipboard('ssh-ed25519 AAAA', 'darwin');
|
||||||
|
|
||||||
|
expect(tool).toBe('pbcopy');
|
||||||
|
expect(execa).toHaveBeenCalledWith('pbcopy', [], { input: 'ssh-ed25519 AAAA' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves on from a command that is not installed', async () => {
|
||||||
|
execa.mockImplementation(async (binary: string) => {
|
||||||
|
if (binary !== 'xclip') {
|
||||||
|
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
|
||||||
|
}
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await copyToClipboard('key', 'linux')).toBe('xclip');
|
||||||
|
expect(execa.mock.calls.map((c) => c[0])).toEqual(['wl-copy', 'xclip']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports that nothing was available rather than throwing', async () => {
|
||||||
|
notFound();
|
||||||
|
|
||||||
|
expect(await copyToClipboard('key', 'linux')).toBeNull();
|
||||||
|
expect(execa).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a command that ran and refused', async () => {
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('failed'), { stderr: 'Error: No protocol specified' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tool with an opinion is not an absent tool: trying the next one would
|
||||||
|
// hide a real problem behind a second failure.
|
||||||
|
await expect(copyToClipboard('key', 'linux')).rejects.toThrow('No protocol specified');
|
||||||
|
expect(execa).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
describeConfig,
|
||||||
getConfigPaths,
|
getConfigPaths,
|
||||||
type KeymanConfigFile,
|
type KeymanConfigFile,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
@@ -208,49 +209,82 @@ describe('keyman config', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('merge strategy', () => {
|
describe('unknown keys', () => {
|
||||||
it('honours an explicit override strategy', () => {
|
/** What a config file is likely to get wrong: the casing of a real key. */
|
||||||
write(rootDir, { vaultRoot: '/parent-vault' });
|
const TYPO = { vaultroot: '/somewhere-else' } as unknown as KeymanConfigFile;
|
||||||
const child = path.join(rootDir, 'nested');
|
|
||||||
write(child, { vaultRoot: '/child-vault', resolution: { vaultRoot: 'override' } });
|
|
||||||
process.chdir(child);
|
|
||||||
|
|
||||||
expect(loadConfig().vaultRoot).toBe('/child-vault');
|
it('names the file, the key and what it could have been', () => {
|
||||||
|
write(rootDir, TYPO);
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
const warned = messages(warnSpy);
|
||||||
|
expect(warned).toContain(path.join(rootDir, '.keymanrc.json'));
|
||||||
|
expect(warned).toContain('vaultroot');
|
||||||
|
// Without the list of known keys the warning says a key is wrong without
|
||||||
|
// saying what right looks like, which for a casing slip is most of the work.
|
||||||
|
expect(warned).toContain('vaultRoot');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never surfaces the resolution key in the loaded config', () => {
|
it('still applies the keys it does understand', () => {
|
||||||
write(rootDir, { keysDir: 'my-keys', resolution: { keysDir: 'override' } });
|
write(rootDir, { ...TYPO, keysDir: 'my-keys' });
|
||||||
|
|
||||||
expect(loadConfig()).not.toHaveProperty('resolution');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates and drops array-valued keys the schema does not define', () => {
|
|
||||||
write(rootDir, { extra: ['a', 'b'] } as unknown as KeymanConfigFile);
|
|
||||||
const child = path.join(rootDir, 'nested');
|
|
||||||
write(child, { extra: ['b', 'c'], keysDir: 'my-keys' } as unknown as KeymanConfigFile);
|
|
||||||
process.chdir(child);
|
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
expect(config).toEqual({ ...DEFAULTS, keysDir: 'my-keys' });
|
expect(config.keysDir).toBe('my-keys');
|
||||||
|
expect(config.vaultRoot).toBe(DEFAULTS.vaultRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates and drops object-valued keys the schema does not define', () => {
|
it('blames the file that said it, not the merged result', () => {
|
||||||
write(rootDir, { extra: { a: 1 } } as unknown as KeymanConfigFile);
|
write(rootDir, {});
|
||||||
const child = path.join(rootDir, 'nested');
|
const child = path.join(rootDir, 'nested');
|
||||||
write(child, { extra: { a: 2, b: 3 } } as unknown as KeymanConfigFile);
|
write(child, TYPO);
|
||||||
process.chdir(child);
|
process.chdir(child);
|
||||||
|
|
||||||
expect(loadConfig()).toEqual(DEFAULTS);
|
loadConfig();
|
||||||
|
|
||||||
|
expect(messages(warnSpy)).toContain(path.join(child, '.keymanrc.json'));
|
||||||
|
expect(messages(warnSpy)).not.toContain(path.join(rootDir, '.keymanrc.json'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates arrays of objects, which cannot be de-duplicated', () => {
|
it('lists every unknown key in one warning per file', () => {
|
||||||
write(rootDir, { extra: [{ a: 1 }] } as unknown as KeymanConfigFile);
|
write(rootDir, { nope: 1, alsoNope: 2 } as unknown as KeymanConfigFile);
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(messages(warnSpy)).toContain('nope, alsoNope');
|
||||||
|
expect(messages(warnSpy)).toContain('unknown keys');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says key, singular, for one of them', () => {
|
||||||
|
write(rootDir, TYPO);
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
expect(messages(warnSpy)).toContain('unknown key ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about a file that sets only known keys', () => {
|
||||||
|
write(rootDir, { keysDir: 'my-keys', tmpDir: 'my-tmp' });
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['array-valued', { extra: ['a', 'b'] }],
|
||||||
|
['object-valued', { extra: { a: 1 } }],
|
||||||
|
['an array of objects', { extra: [{ a: 1 }] }],
|
||||||
|
])('drops a %s unknown key rather than merging it in', (_label, extra) => {
|
||||||
|
write(rootDir, extra as unknown as KeymanConfigFile);
|
||||||
const child = path.join(rootDir, 'nested');
|
const child = path.join(rootDir, 'nested');
|
||||||
write(child, { extra: [{ a: 2 }] } as unknown as KeymanConfigFile);
|
write(child, { ...extra, keysDir: 'my-keys' } as unknown as KeymanConfigFile);
|
||||||
process.chdir(child);
|
process.chdir(child);
|
||||||
|
|
||||||
expect(loadConfig()).toEqual(DEFAULTS);
|
// The schema strips them; nothing in keyman merges an array or an object.
|
||||||
|
expect(loadConfig()).toEqual({ ...DEFAULTS, keysDir: 'my-keys' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,4 +325,27 @@ describe('keyman config', () => {
|
|||||||
expect(paths.keysDir).toBe('/elsewhere/keys');
|
expect(paths.keysDir).toBe('/elsewhere/keys');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('describeConfig', () => {
|
||||||
|
it('reports the resolved paths and the files they came from', () => {
|
||||||
|
write(rootDir, { keysDir: 'my-keys', vaultRoot: 'vault' });
|
||||||
|
const child = path.join(rootDir, 'nested');
|
||||||
|
write(child, { tmpDir: 'my-tmp' });
|
||||||
|
process.chdir(child);
|
||||||
|
|
||||||
|
expect(describeConfig()).toEqual({
|
||||||
|
vaultRoot: path.join(rootDir, 'vault'),
|
||||||
|
keysDir: path.join(rootDir, 'vault', 'my-keys'),
|
||||||
|
tmpDir: path.join(rootDir, 'vault', 'my-tmp'),
|
||||||
|
keyPath: path.join(rootDir, 'vault', 'age.key'),
|
||||||
|
// Parent first, the order they were merged in — which is the only way to
|
||||||
|
// read a surprising value back to the file responsible for it.
|
||||||
|
configFiles: [path.join(rootDir, '.keymanrc.json'), path.join(child, '.keymanrc.json')],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an empty list when nothing was found', () => {
|
||||||
|
expect(describeConfig().configFiles).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const { execa, prompt, stdin } = vi.hoisted(() => ({
|
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
|
||||||
execa: vi.fn(),
|
|
||||||
prompt: vi.fn(),
|
|
||||||
stdin: { write: vi.fn(), end: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('execa', () => ({ execa }));
|
vi.mock('execa', () => ({ execa }));
|
||||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||||
@@ -27,6 +23,7 @@ describe('copyKey', () => {
|
|||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
const touch = (dir: string, file: string, contents = '') => {
|
const touch = (dir: string, file: string, contents = '') => {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -39,6 +36,9 @@ describe('copyKey', () => {
|
|||||||
/** The choices offered by the last inquirer.prompt call. */
|
/** The choices offered by the last inquirer.prompt call. */
|
||||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||||
|
|
||||||
|
/** What was piped into the clipboard command. */
|
||||||
|
const piped = () => (execa.mock.calls.at(-1)?.[2] as { input?: string } | undefined)?.input;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-')));
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-')));
|
||||||
@@ -46,9 +46,9 @@ describe('copyKey', () => {
|
|||||||
tmpDir = path.join(root, 'tmp');
|
tmpDir = path.join(root, 'tmp');
|
||||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
const proc = Object.assign(Promise.resolve({ exitCode: 0 }), { stdin });
|
execa.mockResolvedValue({ stdout: '' });
|
||||||
execa.mockReturnValue(proc);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -93,9 +93,7 @@ describe('copyKey', () => {
|
|||||||
|
|
||||||
await copyKey(sshDir, tmpDir);
|
await copyKey(sshDir, tmpDir);
|
||||||
|
|
||||||
expect(execa).toHaveBeenCalledWith('pbcopy');
|
expect(piped()).toBe('ssh-ed25519 AAAA tmp');
|
||||||
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA tmp');
|
|
||||||
expect(stdin.end).toHaveBeenCalled();
|
|
||||||
expect(messages(logSpy)).toContain('copied to clipboard');
|
expect(messages(logSpy)).toContain('copied to clipboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +104,7 @@ describe('copyKey', () => {
|
|||||||
|
|
||||||
await copyKey(sshDir, tmpDir);
|
await copyKey(sshDir, tmpDir);
|
||||||
|
|
||||||
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA ssh');
|
expect(piped()).toBe('ssh-ed25519 AAAA ssh');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a missing public key without invoking the clipboard', async () => {
|
it('reports a missing public key without invoking the clipboard', async () => {
|
||||||
@@ -123,11 +121,38 @@ describe('copyKey', () => {
|
|||||||
touch(sshDir, 'id_prod');
|
touch(sshDir, 'id_prod');
|
||||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
|
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
|
||||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||||
execa.mockImplementation(() => {
|
execa.mockRejectedValue(Object.assign(new Error('refused'), { stderr: 'no display' }));
|
||||||
throw new Error('pbcopy missing');
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined();
|
await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined();
|
||||||
expect(messages(errorSpy)).toContain('Failed to copy to clipboard');
|
expect(messages(errorSpy)).toContain('Failed to copy to clipboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prints the key when no clipboard command exists at all', async () => {
|
||||||
|
touch(sshDir, 'id_prod');
|
||||||
|
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
|
||||||
|
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await copyKey(sshDir, tmpDir);
|
||||||
|
|
||||||
|
// The operation is "give me this public key". Without a clipboard it is still
|
||||||
|
// answerable, and it used to be a dead end on every platform but macOS.
|
||||||
|
expect(messages(logSpy)).toContain('ssh-ed25519 AAAA ssh');
|
||||||
|
expect(messages(warnSpy)).toContain('No clipboard command found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the private keys it cannot manage', async () => {
|
||||||
|
touch(sshDir, 'id_prod');
|
||||||
|
touch(sshDir, 'id_prod.pub', 'PUBLIC');
|
||||||
|
touch(sshDir, 'deploy_ed25519', '-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n');
|
||||||
|
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||||
|
|
||||||
|
await copyKey(sshDir, tmpDir);
|
||||||
|
|
||||||
|
expect(choices()).toEqual(['id_prod']);
|
||||||
|
expect(messages(logSpy)).toContain('deploy_ed25519');
|
||||||
|
expect(messages(logSpy)).toContain('not named id_*');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for decryptKeys.
|
* Tests for decryptKeys.
|
||||||
*
|
*
|
||||||
* age, cp and chmod are all mocked; the assertions cover which keys are
|
* Only `age` is mocked, and its stand-in writes the output file the way age
|
||||||
* offered and exactly where each decrypted key is written.
|
* would: the copy and the chmod are now real fs calls, so the assertions are on
|
||||||
|
* what ends up on disk and at what mode rather than on which binaries were
|
||||||
|
* spawned.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -17,27 +19,35 @@ vi.mock('inquirer', () => ({ default: { prompt } }));
|
|||||||
|
|
||||||
import { decryptKeys } from '../src/keyman.decrypt.js';
|
import { decryptKeys } from '../src/keyman.decrypt.js';
|
||||||
|
|
||||||
const LOCAL = 'Local (vault/tmp)';
|
const LOCAL = 'local';
|
||||||
const SSH = 'SSH (~/.ssh)';
|
const SSH = 'ssh';
|
||||||
|
|
||||||
describe('decryptKeys', () => {
|
describe('decryptKeys', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
let sshDir: string;
|
let sshDir: string;
|
||||||
let vaultDir: string;
|
let vaultDir: string;
|
||||||
let keyDir: string;
|
let keysDir: string;
|
||||||
|
let tmpDir: string;
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
const AGE_KEY = '/vault/age.key';
|
const AGE_KEY = '/vault/age.key';
|
||||||
|
|
||||||
/** Creates <vault>/keys/<name>/id_<name>.{age,pub}. */
|
/** Creates <vault>/keys/<name>/id_<name>.{age,pub}. */
|
||||||
const vaultKey = (name: string) => {
|
const vaultKey = (name: string) => {
|
||||||
const dir = path.join(keyDir, name);
|
const dir = path.join(keysDir, name);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(dir, `id_${name}.age`), 'ENCRYPTED');
|
fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`);
|
||||||
fs.writeFileSync(path.join(dir, `id_${name}.pub`), 'PUBLIC');
|
fs.writeFileSync(path.join(dir, `id_${name}.pub`), `PUBLIC ${name}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
/** Answers the selection prompt, then every overwrite confirmation. */
|
||||||
|
const answers = (selectedKeys: string[], decryptMode = LOCAL, overwrite = false) => {
|
||||||
|
prompt.mockImplementation(async (questions: { name: string }[]) =>
|
||||||
|
questions[0].name === 'selectedKeys' ? { selectedKeys, decryptMode } : { overwrite }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const choices = () => prompt.mock.calls[0]?.[0][0].choices as string[];
|
||||||
|
|
||||||
const argsOf = (binary: string) =>
|
const argsOf = (binary: string) =>
|
||||||
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
||||||
@@ -45,16 +55,26 @@ describe('decryptKeys', () => {
|
|||||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
const modeOf = (file: string) => fs.statSync(file).mode & 0o777;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-')));
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-')));
|
||||||
sshDir = path.join(root, '.ssh');
|
sshDir = path.join(root, '.ssh');
|
||||||
vaultDir = path.join(root, 'vault');
|
vaultDir = path.join(root, 'vault');
|
||||||
keyDir = path.join(vaultDir, 'keys');
|
keysDir = path.join(vaultDir, 'keys');
|
||||||
fs.mkdirSync(keyDir, { recursive: true });
|
tmpDir = path.join(vaultDir, 'tmp');
|
||||||
|
fs.mkdirSync(keysDir, { recursive: true });
|
||||||
fs.mkdirSync(sshDir, { recursive: true });
|
fs.mkdirSync(sshDir, { recursive: true });
|
||||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
execa.mockResolvedValue({ exitCode: 0 });
|
|
||||||
|
// Stand in for `age -d`: write the plaintext to -o, 0644 as age does.
|
||||||
|
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||||
|
const out = args[args.indexOf('-o') + 1];
|
||||||
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||||
|
fs.writeFileSync(out, 'PLAINTEXT', { mode: 0o644 });
|
||||||
|
return { exitCode: 0 };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -63,72 +83,200 @@ describe('decryptKeys', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('warns when the vault holds no encrypted keys', async () => {
|
it('warns when the vault holds no encrypted keys', async () => {
|
||||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
expect(messages(logSpy)).toContain('No encrypted keys found.');
|
expect(messages(logSpy)).toContain('No encrypted keys found.');
|
||||||
expect(prompt).not.toHaveBeenCalled();
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('warns instead of throwing when the vault has no keys directory', async () => {
|
||||||
|
fs.rmSync(keysDir, { recursive: true });
|
||||||
|
|
||||||
|
await expect(decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY)).resolves.toBeUndefined();
|
||||||
|
expect(messages(logSpy)).toContain('No encrypted keys found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing age binary rather than an ENOENT', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
answers(['prod']);
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY)).rejects.toThrow(
|
||||||
|
'`age` was not found on PATH'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('offers only directories that actually contain an encrypted key', async () => {
|
it('offers only directories that actually contain an encrypted key', async () => {
|
||||||
vaultKey('prod');
|
vaultKey('prod');
|
||||||
fs.mkdirSync(path.join(keyDir, 'empty'), { recursive: true });
|
fs.mkdirSync(path.join(keysDir, 'empty'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(keyDir, 'README.md'), '');
|
fs.writeFileSync(path.join(keysDir, 'README.md'), '');
|
||||||
prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL });
|
answers([]);
|
||||||
|
|
||||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
expect(choices()).toEqual(['prod']);
|
expect(choices()).toEqual(['prod']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypts into the vault tmp directory', async () => {
|
it('decrypts into the vault tmp directory', async () => {
|
||||||
vaultKey('prod');
|
vaultKey('prod');
|
||||||
prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: LOCAL });
|
answers(['prod']);
|
||||||
|
|
||||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
const out = path.join(vaultDir, 'tmp', 'id_prod');
|
const out = path.join(tmpDir, 'id_prod');
|
||||||
expect(argsOf('age')).toEqual([
|
expect(argsOf('age')).toEqual([
|
||||||
'-d',
|
'-d',
|
||||||
'-i',
|
'-i',
|
||||||
AGE_KEY,
|
AGE_KEY,
|
||||||
'-o',
|
'-o',
|
||||||
out,
|
out,
|
||||||
path.join(keyDir, 'prod', 'id_prod.age'),
|
path.join(keysDir, 'prod', 'id_prod.age'),
|
||||||
]);
|
]);
|
||||||
expect(argsOf('cp')).toEqual([path.join(keyDir, 'prod', 'id_prod.pub'), `${out}.pub`]);
|
expect(fs.readFileSync(out, 'utf-8')).toBe('PLAINTEXT');
|
||||||
expect(argsOf('chmod')).toEqual(['600', out]);
|
expect(fs.readFileSync(`${out}.pub`, 'utf-8')).toBe('PUBLIC prod');
|
||||||
expect(messages(logSpy)).toContain(`Decrypted: ${out}`);
|
expect(messages(logSpy)).toContain(`Decrypted: ${out}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypts into the .ssh directory when asked', async () => {
|
it('decrypts into the .ssh directory when asked', async () => {
|
||||||
vaultKey('prod');
|
vaultKey('prod');
|
||||||
prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: SSH });
|
answers(['prod'], SSH);
|
||||||
|
|
||||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
const out = path.join(sshDir, 'id_prod');
|
const out = path.join(sshDir, 'id_prod');
|
||||||
expect(argsOf('age')?.[4]).toBe(out);
|
expect(argsOf('age')?.[4]).toBe(out);
|
||||||
expect(argsOf('cp')?.[1]).toBe(`${out}.pub`);
|
expect(fs.readFileSync(`${out}.pub`, 'utf-8')).toBe('PUBLIC prod');
|
||||||
expect(argsOf('chmod')).toEqual(['600', out]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypts every selected key', async () => {
|
it('creates the .ssh directory when it does not exist, private to the owner', async () => {
|
||||||
|
fs.rmSync(sshDir, { recursive: true });
|
||||||
|
vaultKey('prod');
|
||||||
|
answers(['prod'], SSH);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(modeOf(sshDir)).toBe(0o700);
|
||||||
|
expect(fs.existsSync(path.join(sshDir, 'id_prod'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the private key at 0600, never observable at what age wrote', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
answers(['prod']);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(modeOf(path.join(tmpDir, 'id_prod'))).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypts every selected key with one spawn each', async () => {
|
||||||
vaultKey('prod');
|
vaultKey('prod');
|
||||||
vaultKey('stage');
|
vaultKey('stage');
|
||||||
prompt.mockResolvedValue({ selectedKeys: ['prod', 'stage'], decryptMode: LOCAL });
|
answers(['prod', 'stage']);
|
||||||
|
|
||||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
// age, cp and chmod for each of the two keys.
|
// One age per key: the cp and chmod spawns are gone.
|
||||||
expect(execa).toHaveBeenCalledTimes(6);
|
expect(execa).toHaveBeenCalledTimes(2);
|
||||||
|
expect(execa.mock.calls.every((c) => c[0] === 'age')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when the selection is empty', async () => {
|
it('does nothing when the selection is empty', async () => {
|
||||||
vaultKey('prod');
|
vaultKey('prod');
|
||||||
prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL });
|
answers([]);
|
||||||
|
|
||||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
expect(execa).not.toHaveBeenCalled();
|
expect(execa).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes the private key even when the vault entry has no public key', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
fs.rmSync(path.join(keysDir, 'prod', 'id_prod.pub'));
|
||||||
|
answers(['prod']);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true);
|
||||||
|
expect(messages(logSpy)).toContain('has no public key in the vault');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the target already exists', () => {
|
||||||
|
const existing = (dir: string, name = 'id_prod') => {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, name), 'PRECIOUS EXISTING KEY');
|
||||||
|
return path.join(dir, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps the existing key by default', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
const target = existing(tmpDir);
|
||||||
|
answers(['prod']);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY');
|
||||||
|
expect(execa).not.toHaveBeenCalled();
|
||||||
|
expect(messages(logSpy)).toContain('Skipped prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before overwriting, defaulting to no', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
existing(tmpDir);
|
||||||
|
answers(['prod']);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
const confirm = prompt.mock.calls.at(-1)?.[0][0];
|
||||||
|
expect(confirm).toMatchObject({ type: 'confirm', default: false });
|
||||||
|
expect(confirm.message).toContain(path.join(tmpDir, 'id_prod'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites once confirmed', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
const target = existing(tmpDir);
|
||||||
|
answers(['prod'], LOCAL, true);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(target, 'utf-8')).toBe('PLAINTEXT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks about an existing public key too', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
existing(tmpDir, 'id_prod.pub');
|
||||||
|
answers(['prod']);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(execa).not.toHaveBeenCalled();
|
||||||
|
expect(prompt.mock.calls.at(-1)?.[0][0].message).toContain('id_prod.pub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('protects a key in .ssh the same way', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
const target = existing(sshDir);
|
||||||
|
answers(['prod'], SSH);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles every collision before decrypting anything', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('stage');
|
||||||
|
existing(tmpDir);
|
||||||
|
answers(['prod', 'stage']);
|
||||||
|
|
||||||
|
await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
|
||||||
|
|
||||||
|
// stage is written, prod is kept — and the question about prod was asked
|
||||||
|
// before either was touched.
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'id_stage'))).toBe(true);
|
||||||
|
expect(fs.readFileSync(path.join(tmpDir, 'id_prod'), 'utf-8')).toBe('PRECIOUS EXISTING KEY');
|
||||||
|
expect(execa).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { encryptKeys } from '../src/keyman.encrypt.js';
|
|||||||
describe('encryptKeys', () => {
|
describe('encryptKeys', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
let sshDir: string;
|
let sshDir: string;
|
||||||
let vaultDir: string;
|
let keysDir: string;
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe('encryptKeys', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-')));
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-')));
|
||||||
sshDir = path.join(root, '.ssh');
|
sshDir = path.join(root, '.ssh');
|
||||||
vaultDir = path.join(root, 'vault');
|
keysDir = path.join(root, 'vault', 'keys');
|
||||||
tmpDir = path.join(root, 'vault', 'tmp');
|
tmpDir = path.join(root, 'vault', 'tmp');
|
||||||
fs.mkdirSync(sshDir, { recursive: true });
|
fs.mkdirSync(sshDir, { recursive: true });
|
||||||
fs.mkdirSync(tmpDir, { recursive: true });
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
@@ -60,17 +60,44 @@ describe('encryptKeys', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('warns when there is nothing to encrypt', async () => {
|
it('warns when there is nothing to encrypt', async () => {
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||||
expect(prompt).not.toHaveBeenCalled();
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('warns instead of throwing when the .ssh directory does not exist', async () => {
|
||||||
|
fs.rmSync(sshDir, { recursive: true });
|
||||||
|
|
||||||
|
await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).resolves.toBeUndefined();
|
||||||
|
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still offers the .ssh keys when the tmp directory does not exist', async () => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true });
|
||||||
|
key(sshDir, 'id_prod', 'ssh');
|
||||||
|
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||||
|
|
||||||
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(choices()).toEqual(['id_prod']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing age binary rather than an ENOENT', async () => {
|
||||||
|
key(sshDir, 'id_prod', 'ssh');
|
||||||
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||||
|
execa.mockRejectedValue(Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' }));
|
||||||
|
|
||||||
|
await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).rejects.toThrow(
|
||||||
|
'`age` was not found on PATH'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores public keys and unrelated files when building the list', async () => {
|
it('ignores public keys and unrelated files when building the list', async () => {
|
||||||
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
|
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
|
||||||
fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC');
|
fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC');
|
||||||
|
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||||
});
|
});
|
||||||
@@ -81,7 +108,7 @@ describe('encryptKeys', () => {
|
|||||||
key(tmpDir, 'id_stage', 'tmp');
|
key(tmpDir, 'id_stage', 'tmp');
|
||||||
prompt.mockResolvedValue({ selectedKeys: [] });
|
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||||
|
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
expect(choices()).toEqual(['id_prod', 'id_stage']);
|
expect(choices()).toEqual(['id_prod', 'id_stage']);
|
||||||
});
|
});
|
||||||
@@ -90,9 +117,9 @@ describe('encryptKeys', () => {
|
|||||||
key(sshDir, 'id_prod', 'ssh');
|
key(sshDir, 'id_prod', 'ssh');
|
||||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||||
|
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
const vaultPath = path.join(vaultDir, 'keys', 'prod');
|
const vaultPath = path.join(keysDir, 'prod');
|
||||||
expect(execa).toHaveBeenCalledWith('age', [
|
expect(execa).toHaveBeenCalledWith('age', [
|
||||||
'-r',
|
'-r',
|
||||||
PUBKEY,
|
PUBKEY,
|
||||||
@@ -109,12 +136,10 @@ describe('encryptKeys', () => {
|
|||||||
key(tmpDir, 'id_prod', 'tmp');
|
key(tmpDir, 'id_prod', 'tmp');
|
||||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||||
|
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod'));
|
expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod'));
|
||||||
expect(fs.readFileSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.pub'), 'utf-8')).toBe(
|
expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'utf-8')).toBe('PUBLIC tmp');
|
||||||
'PUBLIC tmp'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('encrypts every selected key', async () => {
|
it('encrypts every selected key', async () => {
|
||||||
@@ -122,20 +147,125 @@ describe('encryptKeys', () => {
|
|||||||
key(sshDir, 'id_stage', 'ssh');
|
key(sshDir, 'id_stage', 'ssh');
|
||||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
|
||||||
|
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
expect(execa).toHaveBeenCalledTimes(2);
|
expect(execa).toHaveBeenCalledTimes(2);
|
||||||
expect(fs.existsSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.age'))).toBe(true);
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(vaultDir, 'keys', 'stage', 'id_stage.age'))).toBe(true);
|
expect(fs.existsSync(path.join(keysDir, 'stage', 'id_stage.age'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when the selection is empty', async () => {
|
it('does nothing when the selection is empty', async () => {
|
||||||
key(sshDir, 'id_prod', 'ssh');
|
key(sshDir, 'id_prod', 'ssh');
|
||||||
prompt.mockResolvedValue({ selectedKeys: [] });
|
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||||
|
|
||||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
expect(execa).not.toHaveBeenCalled();
|
expect(execa).not.toHaveBeenCalled();
|
||||||
expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false);
|
expect(fs.existsSync(keysDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a key with no .pub file', () => {
|
||||||
|
/** A private key without its sibling — what the selection list offers anyway. */
|
||||||
|
const orphan = (dir: string, name: string) => {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, name), `PRIVATE ${name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('derives the public key with ssh-keygen', async () => {
|
||||||
|
orphan(sshDir, 'id_prod');
|
||||||
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||||
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
|
if (binary === 'ssh-keygen') return { stdout: 'ssh-ed25519 AAAA derived' };
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(execa).toHaveBeenCalledWith(
|
||||||
|
'ssh-keygen',
|
||||||
|
['-y', '-f', path.join(sshDir, 'id_prod')],
|
||||||
|
// stderr inherited so the passphrase prompt is visible, stdout piped so
|
||||||
|
// the derived key can be captured.
|
||||||
|
{ stdio: ['inherit', 'pipe', 'inherit'] }
|
||||||
|
);
|
||||||
|
expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'utf-8')).toBe(
|
||||||
|
'ssh-ed25519 AAAA derived\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the private key alone when the derivation fails', async () => {
|
||||||
|
orphan(sshDir, 'id_prod');
|
||||||
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
|
if (binary === 'ssh-keygen') {
|
||||||
|
throw Object.assign(new Error('bad passphrase'), { stderr: 'incorrect passphrase' });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
// The encrypted key is what matters; the .pub is recoverable from it later.
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false);
|
||||||
|
expect(messages(warnSpy)).toContain('no public key could be derived');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when one key of several fails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
key(sshDir, 'id_prod', 'ssh');
|
||||||
|
key(sshDir, 'id_stage', 'ssh');
|
||||||
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
|
||||||
|
// age refuses the first key only.
|
||||||
|
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||||
|
if (args.some((arg) => arg.endsWith('id_prod'))) {
|
||||||
|
throw Object.assign(new Error('age refused'), { stderr: 'no identity' });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still encrypts the rest', async () => {
|
||||||
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'stage', 'id_stage.age'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports which keys were not stored', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(messages(errorSpy)).toContain('id_prod');
|
||||||
|
expect(messages(logSpy)).toContain('1 of 2 selected keys were not stored: id_prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no vault entry for the key that failed', async () => {
|
||||||
|
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
// Not even an empty directory: list counts a directory with an .age in it,
|
||||||
|
// and a truncated .age would be offered for decryption.
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up immediately when age is not installed', async () => {
|
||||||
|
key(sshDir, 'id_prod', 'ssh');
|
||||||
|
key(sshDir, 'id_stage', 'ssh');
|
||||||
|
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not a per-key failure: nine more identical errors help nobody.
|
||||||
|
await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).rejects.toThrow(
|
||||||
|
'`age` was not found on PATH'
|
||||||
|
);
|
||||||
|
expect(execa).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ describe('generateKey', () => {
|
|||||||
const argsOf = (binary: string) =>
|
const argsOf = (binary: string) =>
|
||||||
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
||||||
|
|
||||||
|
/** The options of the mocked call to `binary`. */
|
||||||
|
const optionsOf = (binary: string) =>
|
||||||
|
execa.mock.calls.find((c) => c[0] === binary)?.[2] as { stdio?: unknown } | undefined;
|
||||||
|
|
||||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
@@ -64,7 +68,7 @@ describe('generateKey', () => {
|
|||||||
return { exitCode: 0 };
|
return { exitCode: 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
answer({ algorithm: 'ed25519', keyName: 'prod', password: 'pw', identity: 'me@host' });
|
answer({ algorithm: 'ed25519', keyName: 'prod', identity: 'me@host' });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -80,16 +84,24 @@ describe('generateKey', () => {
|
|||||||
'ed25519',
|
'ed25519',
|
||||||
'-f',
|
'-f',
|
||||||
path.join(tmpDir, 'id_prod'),
|
path.join(tmpDir, 'id_prod'),
|
||||||
'-N',
|
|
||||||
'pw',
|
|
||||||
'-C',
|
'-C',
|
||||||
'me@host',
|
'me@host',
|
||||||
]);
|
]);
|
||||||
expect(messages(logSpy)).toContain('Key generated');
|
expect(messages(logSpy)).toContain('Key generated');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never handles the passphrase itself', async () => {
|
||||||
|
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||||
|
|
||||||
|
// No -N, so ssh-keygen prompts and confirms; inherited stdio is what makes
|
||||||
|
// that prompt reach the terminal. The passphrase never touches argv.
|
||||||
|
expect(argsOf('ssh-keygen')).not.toContain('-N');
|
||||||
|
expect(optionsOf('ssh-keygen')).toEqual({ stdio: 'inherit' });
|
||||||
|
expect(prompt.mock.calls.map((c) => c[0][0].name)).not.toContain('password');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not prefix a key name that already starts with id_', async () => {
|
it('does not prefix a key name that already starts with id_', async () => {
|
||||||
answer({ algorithm: 'ed25519', keyName: 'id_prod', password: '', identity: '' });
|
answer({ algorithm: 'ed25519', keyName: 'id_prod', identity: '' });
|
||||||
|
|
||||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||||
|
|
||||||
@@ -97,7 +109,7 @@ describe('generateKey', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('requests a 4096 bit key for rsa', async () => {
|
it('requests a 4096 bit key for rsa', async () => {
|
||||||
answer({ algorithm: 'rsa', keyName: 'prod', password: '', identity: '' });
|
answer({ algorithm: 'rsa', keyName: 'prod', identity: '' });
|
||||||
|
|
||||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||||
|
|
||||||
@@ -139,17 +151,22 @@ describe('generateKey', () => {
|
|||||||
expect(fs.readFileSync(path.join(tmpDir, 'id_prod'), 'utf-8')).toBe('EXISTING');
|
expect(fs.readFileSync(path.join(tmpDir, 'id_prod'), 'utf-8')).toBe('EXISTING');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a failure from ssh-keygen without leaving a vault entry', async () => {
|
it('reports a failure from ssh-keygen without reaching age', async () => {
|
||||||
execa.mockRejectedValue(new Error('ssh-keygen exploded'));
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('ssh-keygen exploded'), { stderr: 'ssh-keygen exploded' });
|
||||||
|
});
|
||||||
|
|
||||||
await expect(generateKey(tmpDir, keysDir, PUBKEY)).resolves.toBeUndefined();
|
await expect(generateKey(tmpDir, keysDir, PUBKEY)).resolves.toBeUndefined();
|
||||||
expect(messages(errorSpy)).toContain('Error generating/encrypting key');
|
expect(messages(errorSpy)).toContain('Error generating key');
|
||||||
|
expect(argsOf('age')).toBeUndefined();
|
||||||
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a failure from age', async () => {
|
it('reports a failure from age and says the key is still there to encrypt', async () => {
|
||||||
execa.mockImplementation(async (binary: string, args: string[]) => {
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
if (binary === 'age') throw new Error('age exploded');
|
if (binary === 'age') {
|
||||||
|
throw Object.assign(new Error('age exploded'), { stderr: 'age exploded' });
|
||||||
|
}
|
||||||
const keyPath = args[args.indexOf('-f') + 1];
|
const keyPath = args[args.indexOf('-f') + 1];
|
||||||
fs.writeFileSync(keyPath, 'PRIVATE');
|
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||||
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated');
|
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated');
|
||||||
@@ -158,7 +175,11 @@ describe('generateKey', () => {
|
|||||||
|
|
||||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||||
|
|
||||||
expect(messages(errorSpy)).toContain('Error generating/encrypting key');
|
expect(messages(errorSpy)).toContain('Error encrypting key');
|
||||||
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false);
|
// The generated key is the thing of value, and it survived.
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true);
|
||||||
|
expect(messages(errorSpy)).toContain(path.join(tmpDir, 'id_prod'));
|
||||||
|
// And no half-made vault entry was left claiming to hold it.
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Tests for home directory resolution.
|
||||||
|
*
|
||||||
|
* Real directories under os.tmpdir() stand in for home directories, since the
|
||||||
|
* whole point of the module is that it checks whether a path exists rather than
|
||||||
|
* assuming a layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CURRENT_USER, resolveHomeDir } from '../src/keyman.home.js';
|
||||||
|
|
||||||
|
describe('resolveHomeDir', () => {
|
||||||
|
let homes: string;
|
||||||
|
let originalHome: string | undefined;
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const messages = () => errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalHome = process.env.HOME;
|
||||||
|
homes = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-homes-')));
|
||||||
|
process.env.HOME = path.join(homes, 'alice');
|
||||||
|
fs.mkdirSync(process.env.HOME, { recursive: true });
|
||||||
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
}
|
||||||
|
fs.rmSync(homes, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the current user', () => {
|
||||||
|
it('uses HOME when it is set', () => {
|
||||||
|
expect(resolveHomeDir(CURRENT_USER)).toBe(path.join(homes, 'alice'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the passwd entry when HOME is unset', () => {
|
||||||
|
delete process.env.HOME;
|
||||||
|
|
||||||
|
// Not asserted as a literal: what matters is that an unset HOME is no longer
|
||||||
|
// a fatal error, which is what `process.env.HOME || ''` made it.
|
||||||
|
expect(resolveHomeDir(CURRENT_USER)).toBe(os.userInfo().homedir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the failure when neither is available', () => {
|
||||||
|
delete process.env.HOME;
|
||||||
|
vi.spyOn(os, 'userInfo').mockImplementation(() => {
|
||||||
|
throw new Error('no passwd entry for uid');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveHomeDir(CURRENT_USER)).toBeNull();
|
||||||
|
expect(messages()).toContain('Unable to determine HOME directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty passwd home as no answer', () => {
|
||||||
|
delete process.env.HOME;
|
||||||
|
vi.spyOn(os, 'userInfo').mockReturnValue({
|
||||||
|
...os.userInfo(),
|
||||||
|
homedir: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveHomeDir(CURRENT_USER)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('another user', () => {
|
||||||
|
it('looks beside the current home, whatever that directory is called', () => {
|
||||||
|
const bob = path.join(homes, 'bob');
|
||||||
|
fs.mkdirSync(bob);
|
||||||
|
|
||||||
|
// The old code hardcoded /home/<user>, which is wrong on macOS — where homes
|
||||||
|
// live in /Users — and on any host that puts them anywhere else.
|
||||||
|
expect(resolveHomeDir('bob')).toBe(bob);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still tries the conventional locations with no current home to go by', () => {
|
||||||
|
delete process.env.HOME;
|
||||||
|
vi.spyOn(os, 'userInfo').mockImplementation(() => {
|
||||||
|
throw new Error('no passwd entry for uid');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not knowing where *this* user lives is no reason to give up on another.
|
||||||
|
expect(resolveHomeDir('nobody')).toBeNull();
|
||||||
|
expect(messages()).toContain('/home/nobody, /Users/nobody');
|
||||||
|
expect(messages()).not.toContain('Unable to determine HOME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports every path it tried when there is no such home', () => {
|
||||||
|
expect(resolveHomeDir('nobody')).toBeNull();
|
||||||
|
|
||||||
|
expect(messages()).toContain('No home directory found for nobody');
|
||||||
|
expect(messages()).toContain(path.join(homes, 'nobody'));
|
||||||
|
expect(messages()).toContain('/home/nobody');
|
||||||
|
expect(messages()).toContain('/Users/nobody');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still checks the conventional locations when HOME is somewhere odd', () => {
|
||||||
|
process.env.HOME = path.join(homes, 'alice');
|
||||||
|
const conventional = process.platform === 'darwin' ? '/Users' : '/home';
|
||||||
|
const existing = fs
|
||||||
|
.readdirSync(conventional)
|
||||||
|
.find((entry) =>
|
||||||
|
fs.statSync(path.join(conventional, entry), { throwIfNoEntry: false })?.isDirectory()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skipped rather than asserted blind if the machine has no such user.
|
||||||
|
if (existing) {
|
||||||
|
expect(resolveHomeDir(existing)).toBe(path.join(conventional, existing));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Tests for private key discovery.
|
||||||
|
*
|
||||||
|
* Real files, because the classification is a bounded read of a real header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { reportSkippedKeys, scanPrivateKeys } from '../src/keyman.keys.js';
|
||||||
|
|
||||||
|
const OPENSSH = '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n';
|
||||||
|
const RSA_PEM = '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n';
|
||||||
|
const PKCS8 = '-----BEGIN PRIVATE KEY-----\nMIIB\n';
|
||||||
|
|
||||||
|
describe('scanPrivateKeys', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
const write = (name: string, contents: string) =>
|
||||||
|
fs.writeFileSync(path.join(dir, name), contents);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-keys-')));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for a directory that does not exist', () => {
|
||||||
|
expect(scanPrivateKeys(path.join(dir, 'nope'))).toEqual({ keys: [], skipped: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the id_ keys and not their public halves', () => {
|
||||||
|
write('id_prod', OPENSSH);
|
||||||
|
write('id_prod.pub', 'ssh-ed25519 AAAA');
|
||||||
|
|
||||||
|
expect(scanPrivateKeys(dir)).toEqual({ keys: ['id_prod'], skipped: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts the keys, so the menu order does not come from the filesystem', () => {
|
||||||
|
for (const name of ['id_stage', 'id_alpha', 'id_prod']) {
|
||||||
|
write(name, OPENSSH);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scanPrivateKeys(dir).keys).toEqual(['id_alpha', 'id_prod', 'id_stage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers an id_ file without checking what is in it', () => {
|
||||||
|
// Unchanged from before the scan existed: whatever was offered still is.
|
||||||
|
write('id_prod', 'not a key at all');
|
||||||
|
|
||||||
|
expect(scanPrivateKeys(dir).keys).toEqual(['id_prod']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an OpenSSH key', OPENSSH],
|
||||||
|
['an encrypted PEM key', RSA_PEM],
|
||||||
|
['a PKCS#8 key', PKCS8],
|
||||||
|
])('reports %s that is not named id_*', (_label, contents) => {
|
||||||
|
write('deploy_ed25519', contents);
|
||||||
|
|
||||||
|
expect(scanPrivateKeys(dir)).toEqual({ keys: [], skipped: ['deploy_ed25519'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the other files a .ssh directory is full of', () => {
|
||||||
|
write('known_hosts', 'github.com ssh-ed25519 AAAA');
|
||||||
|
write('config', 'Host *\n AddKeysToAgent yes\n');
|
||||||
|
write('authorized_keys', 'ssh-ed25519 AAAA');
|
||||||
|
fs.mkdirSync(path.join(dir, 'sockets'));
|
||||||
|
|
||||||
|
expect(scanPrivateKeys(dir)).toEqual({ keys: [], skipped: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a path it cannot read', () => {
|
||||||
|
// A dangling symlink, not a 0o000 file: root reads a 0o000 file happily, so
|
||||||
|
// the mode-based version of this passed here and failed on the CI runner,
|
||||||
|
// which is a container running as root. ENOENT nobody can override.
|
||||||
|
fs.symlinkSync(path.join(dir, 'gone'), path.join(dir, 'secret'));
|
||||||
|
|
||||||
|
// Reported as not-a-key rather than crashing the menu it was building.
|
||||||
|
expect(scanPrivateKeys(dir).skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read past the header', () => {
|
||||||
|
// The marker is in the first line; a mention further down is not a key.
|
||||||
|
write('decoy', `${'x'.repeat(200)}\nPRIVATE KEY-----\n`);
|
||||||
|
|
||||||
|
expect(scanPrivateKeys(dir).skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportSkippedKeys', () => {
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when nothing was skipped', () => {
|
||||||
|
reportSkippedKeys([], '/home/alice/.ssh');
|
||||||
|
|
||||||
|
expect(logSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the keys, the directory and the reason', () => {
|
||||||
|
reportSkippedKeys(['deploy_ed25519', 'backup_rsa'], '/home/alice/.ssh');
|
||||||
|
|
||||||
|
expect(messages()).toContain('deploy_ed25519, backup_rsa');
|
||||||
|
expect(messages()).toContain('/home/alice/.ssh');
|
||||||
|
expect(messages()).toContain('2 private keys');
|
||||||
|
// Without the reason the message is a complaint rather than an instruction.
|
||||||
|
expect(messages()).toContain('rename');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says key, singular, for one of them', () => {
|
||||||
|
reportSkippedKeys(['deploy_ed25519'], '/home/alice/.ssh');
|
||||||
|
|
||||||
|
expect(messages()).toContain('1 private key ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -182,6 +182,25 @@ describe('listKeys', () => {
|
|||||||
expect(row('id_real')).toBeDefined();
|
expect(row('id_real')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps listing when the vault holds a dangling symlink', async () => {
|
||||||
|
vaultKey('real');
|
||||||
|
fs.symlinkSync(path.join(root, 'gone'), path.join(vaultDir, 'broken'));
|
||||||
|
|
||||||
|
await expect(listKeys(sshDir, vaultDir, tmpDir)).resolves.toBeUndefined();
|
||||||
|
expect(row('id_real')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a symlink pointing at a real vault directory', async () => {
|
||||||
|
const elsewhere = path.join(root, 'elsewhere', 'prod');
|
||||||
|
touch(elsewhere, 'id_prod.age');
|
||||||
|
fs.mkdirSync(vaultDir, { recursive: true });
|
||||||
|
fs.symlinkSync(elsewhere, path.join(vaultDir, 'prod'));
|
||||||
|
|
||||||
|
await listKeys(sshDir, vaultDir, tmpDir);
|
||||||
|
|
||||||
|
expect(row('id_prod')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores loose files sitting next to the vault directories', async () => {
|
it('ignores loose files sitting next to the vault directories', async () => {
|
||||||
vaultKey('real');
|
vaultKey('real');
|
||||||
fs.writeFileSync(path.join(vaultDir, 'README.md'), '');
|
fs.writeFileSync(path.join(vaultDir, 'README.md'), '');
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const {
|
|||||||
generateKey,
|
generateKey,
|
||||||
encryptKeys,
|
encryptKeys,
|
||||||
decryptKeys,
|
decryptKeys,
|
||||||
|
rotateKey,
|
||||||
|
retireKey,
|
||||||
extractAgePublicKey,
|
extractAgePublicKey,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
prompt: vi.fn(),
|
prompt: vi.fn(),
|
||||||
@@ -29,6 +31,8 @@ const {
|
|||||||
generateKey: vi.fn(),
|
generateKey: vi.fn(),
|
||||||
encryptKeys: vi.fn(),
|
encryptKeys: vi.fn(),
|
||||||
decryptKeys: vi.fn(),
|
decryptKeys: vi.fn(),
|
||||||
|
rotateKey: vi.fn(),
|
||||||
|
retireKey: vi.fn(),
|
||||||
extractAgePublicKey: vi.fn(),
|
extractAgePublicKey: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -39,6 +43,7 @@ vi.mock('../src/keyman.copy.js', () => ({ copyKey }));
|
|||||||
vi.mock('../src/keyman.generate.js', () => ({ generateKey }));
|
vi.mock('../src/keyman.generate.js', () => ({ generateKey }));
|
||||||
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
|
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
|
||||||
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
|
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
|
||||||
|
vi.mock('../src/keyman.rotate.js', () => ({ rotateKey, retireKey }));
|
||||||
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
|
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
|
||||||
|
|
||||||
import { keyman } from '../src/keyman.main.js';
|
import { keyman } from '../src/keyman.main.js';
|
||||||
@@ -81,7 +86,7 @@ describe('keyman', () => {
|
|||||||
ageKeyFile: 'age.key',
|
ageKeyFile: 'age.key',
|
||||||
});
|
});
|
||||||
resolveConfigPaths.mockReturnValue(paths);
|
resolveConfigPaths.mockReturnValue(paths);
|
||||||
extractAgePublicKey.mockReturnValue('age1recipient');
|
extractAgePublicKey.mockResolvedValue('age1recipient');
|
||||||
|
|
||||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
@@ -106,6 +111,17 @@ describe('keyman', () => {
|
|||||||
expect(output()).toContain(paths.keyPath);
|
expect(output()).toContain(paths.keyPath);
|
||||||
expect(fs.existsSync(paths.vaultRoot)).toBe(true);
|
expect(fs.existsSync(paths.vaultRoot)).toBe(true);
|
||||||
expect(fs.existsSync(paths.tmpDir)).toBe(true);
|
expect(fs.existsSync(paths.tmpDir)).toBe(true);
|
||||||
|
// keysDir too: decrypt reads it, and nothing created it before the first
|
||||||
|
// encrypt, so a fresh vault could not be decrypted from.
|
||||||
|
expect(fs.existsSync(paths.keysDir)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the vault directories private to the owner', async () => {
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
for (const dir of [paths.vaultRoot, paths.keysDir, paths.tmpDir]) {
|
||||||
|
expect(fs.statSync(dir).mode & 0o777, dir).toBe(0o700);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quits without running any operation', async () => {
|
it('quits without running any operation', async () => {
|
||||||
@@ -125,6 +141,9 @@ describe('keyman', () => {
|
|||||||
'generate',
|
'generate',
|
||||||
'encrypt',
|
'encrypt',
|
||||||
'decrypt',
|
'decrypt',
|
||||||
|
'rotate',
|
||||||
|
'retire',
|
||||||
|
'clear',
|
||||||
'quit',
|
'quit',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -161,31 +180,110 @@ describe('keyman', () => {
|
|||||||
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient');
|
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('encrypts keys into the vault root', async () => {
|
it('encrypts keys into the configured keys directory', async () => {
|
||||||
menu(['encrypt']);
|
menu(['encrypt']);
|
||||||
|
|
||||||
await keyman();
|
await keyman();
|
||||||
|
|
||||||
expect(encryptKeys).toHaveBeenCalledWith(
|
expect(encryptKeys).toHaveBeenCalledWith(
|
||||||
path.join(process.env.HOME as string, '.ssh'),
|
path.join(process.env.HOME as string, '.ssh'),
|
||||||
paths.vaultRoot,
|
paths.keysDir,
|
||||||
paths.tmpDir,
|
paths.tmpDir,
|
||||||
'age1recipient'
|
'age1recipient'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypts keys using the age identity file', async () => {
|
it('decrypts from the configured keys directory using the age identity file', async () => {
|
||||||
menu(['decrypt']);
|
menu(['decrypt']);
|
||||||
|
|
||||||
await keyman();
|
await keyman();
|
||||||
|
|
||||||
expect(decryptKeys).toHaveBeenCalledWith(
|
expect(decryptKeys).toHaveBeenCalledWith(
|
||||||
path.join(process.env.HOME as string, '.ssh'),
|
path.join(process.env.HOME as string, '.ssh'),
|
||||||
paths.vaultRoot,
|
paths.keysDir,
|
||||||
|
paths.tmpDir,
|
||||||
paths.keyPath
|
paths.keyPath
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rotates a key with the age recipient, against the same directories', async () => {
|
||||||
|
menu(['rotate']);
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(rotateKey).toHaveBeenCalledWith(
|
||||||
|
path.join(process.env.HOME as string, '.ssh'),
|
||||||
|
paths.keysDir,
|
||||||
|
paths.tmpDir,
|
||||||
|
'age1recipient'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retires a key without needing a recipient', async () => {
|
||||||
|
menu(['retire']);
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(retireKey).toHaveBeenCalledWith(
|
||||||
|
path.join(process.env.HOME as string, '.ssh'),
|
||||||
|
paths.keysDir,
|
||||||
|
paths.tmpDir
|
||||||
|
);
|
||||||
|
// Retiring only deletes, so it works with no age identity at all.
|
||||||
|
expect(extractAgePublicKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without an age recipient', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
extractAgePublicKey.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['generate', generateKey],
|
||||||
|
['encrypt', encryptKeys],
|
||||||
|
['rotate', rotateKey],
|
||||||
|
])('refuses %s with a remedy instead of passing null to age', async (choice, operation) => {
|
||||||
|
menu([choice]);
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(operation).not.toHaveBeenCalled();
|
||||||
|
const reported = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
expect(reported).toContain(`age-keygen -o ${paths.keyPath}`);
|
||||||
|
// The whole point: the loop survives and quit is still reached.
|
||||||
|
expect(output()).toContain('Goodbye!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows the operations that need no recipient', async () => {
|
||||||
|
menu(['list', 'decrypt', 'retire']);
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(listKeys).toHaveBeenCalled();
|
||||||
|
expect(decryptKeys).toHaveBeenCalled();
|
||||||
|
expect(retireKey).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries the lookup, so creating the identity mid-session works', async () => {
|
||||||
|
extractAgePublicKey.mockResolvedValueOnce(null).mockResolvedValueOnce('age1later');
|
||||||
|
menu(['generate', 'generate']);
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(extractAgePublicKey).toHaveBeenCalledTimes(2);
|
||||||
|
expect(generateKey).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1later');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the recipient once for repeated operations', async () => {
|
||||||
|
menu(['generate', 'encrypt']);
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(extractAgePublicKey).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps showing the menu until the user quits', async () => {
|
it('keeps showing the menu until the user quits', async () => {
|
||||||
menu(['list', 'copy', 'list']);
|
menu(['list', 'copy', 'list']);
|
||||||
|
|
||||||
@@ -196,21 +294,55 @@ describe('keyman', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('targets another user home directory when a user is named', async () => {
|
it('targets another user home directory when a user is named', async () => {
|
||||||
|
// A real sibling of the current HOME, because resolveHomeDir checks that the
|
||||||
|
// directory exists rather than assuming a layout.
|
||||||
|
const deployHome = path.join(root, 'deploy');
|
||||||
|
fs.mkdirSync(deployHome, { recursive: true });
|
||||||
menu(['list'], 'deploy');
|
menu(['list'], 'deploy');
|
||||||
|
|
||||||
await keyman();
|
await keyman();
|
||||||
|
|
||||||
expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir);
|
expect(listKeys).toHaveBeenCalledWith(
|
||||||
|
path.join(deployHome, '.ssh'),
|
||||||
|
paths.keysDir,
|
||||||
|
paths.tmpDir
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aborts when the home directory cannot be determined', async () => {
|
it('aborts when the named user has no home directory', async () => {
|
||||||
delete process.env.HOME;
|
menu(['list'], 'nobody-at-all');
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
throw new Error('process.exit');
|
throw new Error('process.exit');
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(keyman()).rejects.toThrow('process.exit');
|
await expect(keyman()).rejects.toThrow('process.exit');
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory');
|
expect(errorSpy.mock.calls[0][0]).toContain('No home directory found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a .gitignore next to the vault so it cannot be committed', async () => {
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
const contents = fs.readFileSync(path.join(paths.vaultRoot, '.gitignore'), 'utf-8');
|
||||||
|
// The README used to ask the user to do this by hand.
|
||||||
|
expect(contents).toContain('age.key');
|
||||||
|
expect(contents).toContain('tmp/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the decrypted keys on request', async () => {
|
||||||
|
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(paths.tmpDir, 'id_prod'), 'PRIVATE');
|
||||||
|
// Not the `menu` helper: this one has to answer the confirmation too.
|
||||||
|
const queue = ['clear', 'quit'];
|
||||||
|
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||||
|
const { name } = questions[0];
|
||||||
|
if (name === 'user') return { user: '@current' };
|
||||||
|
if (name === 'confirmed') return { confirmed: true };
|
||||||
|
return { category: queue.shift() };
|
||||||
|
});
|
||||||
|
|
||||||
|
await keyman();
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(paths.tmpDir, 'id_prod'))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* The README is the only document that ships (`package.json` files: dist,
|
||||||
|
* README.md, LICENSE), so a reader on the registry sees it and nothing else. It
|
||||||
|
* had drifted to describing four of the menu's entries and none of the command
|
||||||
|
* line; these assertions are the parts that can drift again silently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { helpText } from '../src/keyman.args.js';
|
||||||
|
|
||||||
|
const README = fs.readFileSync(path.join(import.meta.dirname, '..', 'README.md'), 'utf-8');
|
||||||
|
|
||||||
|
describe('README', () => {
|
||||||
|
it('quotes --help verbatim', () => {
|
||||||
|
// Copied rather than described, and checked rather than trusted: a flag added
|
||||||
|
// to helpText() now fails here instead of shipping undocumented.
|
||||||
|
expect(README).toContain(helpText().trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents every menu operation', () => {
|
||||||
|
const main = fs.readFileSync(
|
||||||
|
path.join(import.meta.dirname, '..', 'src', 'keyman.main.ts'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
const labels = [...main.matchAll(/\{ name: '([^']+)', value: '[a-z]+' \}/g)].map((m) => m[1]);
|
||||||
|
|
||||||
|
// The labels themselves, emoji included, so a renamed entry is caught too —
|
||||||
|
// but with runs of whitespace collapsed, because some of them carry a second
|
||||||
|
// space to align a variation-selector emoji in a terminal, and prose should
|
||||||
|
// not have to reproduce that.
|
||||||
|
const collapse = (text: string) => text.replace(/\s+/g, ' ');
|
||||||
|
const readme = collapse(README);
|
||||||
|
|
||||||
|
expect(labels.length).toBe(9);
|
||||||
|
for (const label of labels) {
|
||||||
|
expect(readme, label).toContain(collapse(label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents every configuration key', () => {
|
||||||
|
for (const key of ['vaultRoot', 'keysDir', 'tmpDir', 'ageKeyFile']) {
|
||||||
|
expect(README, key).toContain(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* Tests for rotation and retirement.
|
||||||
|
*
|
||||||
|
* ssh-keygen and age are mocked; the ssh-keygen stand-in writes the pair the real
|
||||||
|
* binary would, so the vault write is a real one. Everything the operations claim
|
||||||
|
* about the filesystem is asserted against the filesystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('execa', () => ({ execa }));
|
||||||
|
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||||
|
|
||||||
|
import { nextRotationName, retireKey, rotateKey, supersededBy } from '../src/keyman.rotate.js';
|
||||||
|
|
||||||
|
describe('nextRotationName', () => {
|
||||||
|
it('starts a series at 2, so the first key keeps its plain name', () => {
|
||||||
|
expect(nextRotationName('prod', ['prod'])).toBe('prod-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues an existing series', () => {
|
||||||
|
expect(nextRotationName('prod-2', ['prod', 'prod-2'])).toBe('prod-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips past a version that already exists', () => {
|
||||||
|
// Rotating the original again after prod-2 and prod-3 exist: -2 is taken.
|
||||||
|
expect(nextRotationName('prod', ['prod', 'prod-2', 'prod-3'])).toBe('prod-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores other series', () => {
|
||||||
|
expect(nextRotationName('prod', ['prod', 'stage-7', 'prod-backup'])).toBe('prod-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a name that ends in a number as its own series', () => {
|
||||||
|
// `web2` is a host name, not a version — the separator is what makes a series.
|
||||||
|
expect(nextRotationName('web2', ['web2'])).toBe('web2-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a hyphenated base intact', () => {
|
||||||
|
expect(nextRotationName('build-agent', ['build-agent'])).toBe('build-agent-2');
|
||||||
|
expect(nextRotationName('build-agent-2', ['build-agent-2'])).toBe('build-agent-3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('supersededBy', () => {
|
||||||
|
it('finds the replacement of a key', () => {
|
||||||
|
expect(supersededBy('prod', ['prod', 'prod-2'])).toBe('prod-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers with the latest one', () => {
|
||||||
|
expect(supersededBy('prod', ['prod', 'prod-2', 'prod-3'])).toBe('prod-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing supersedes the newest key in a series', () => {
|
||||||
|
expect(supersededBy('prod-3', ['prod', 'prod-2', 'prod-3'])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count an unrelated key', () => {
|
||||||
|
expect(supersededBy('prod', ['prod', 'stage-9'])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotateKey', () => {
|
||||||
|
let root: string;
|
||||||
|
let sshDir: string;
|
||||||
|
let keysDir: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const PUBKEY = 'age1recipient';
|
||||||
|
|
||||||
|
/** Creates <keysDir>/<name>/id_<name>.{age,pub}. */
|
||||||
|
const vaultKey = (name: string, comment = 'me@host') => {
|
||||||
|
const dir = path.join(keysDir, name);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`);
|
||||||
|
fs.writeFileSync(path.join(dir, `id_${name}.pub`), `ssh-ed25519 AAAA${name} ${comment}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Answers each prompt by the name of the question it asks. */
|
||||||
|
const answer = (answers: Record<string, unknown>) => {
|
||||||
|
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||||
|
const { name } = questions[0];
|
||||||
|
return { [name]: answers[name] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const question = (name: string) =>
|
||||||
|
prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name);
|
||||||
|
|
||||||
|
const argsOf = (binary: string) =>
|
||||||
|
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
||||||
|
|
||||||
|
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||||
|
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-rotate-')));
|
||||||
|
sshDir = path.join(root, '.ssh');
|
||||||
|
keysDir = path.join(root, 'vault', 'keys');
|
||||||
|
tmpDir = path.join(root, 'vault', 'tmp');
|
||||||
|
fs.mkdirSync(keysDir, { recursive: true });
|
||||||
|
fs.mkdirSync(sshDir, { recursive: true });
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
|
if (binary === 'ssh-keygen') {
|
||||||
|
const keyPath = args[args.indexOf('-f') + 1];
|
||||||
|
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||||
|
fs.writeFileSync(`${keyPath}.pub`, `ssh-ed25519 NEWKEY ${args[args.indexOf('-C') + 1]}\n`);
|
||||||
|
}
|
||||||
|
if (binary === 'age') {
|
||||||
|
// Written, not just recorded: what the vault ends up holding is the thing
|
||||||
|
// under test, and a later listing has to see the new entry.
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
}
|
||||||
|
return { exitCode: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
answer({ key: 'prod', algorithm: 'ed25519', identity: 'me@host' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says there is nothing to rotate on an empty vault', async () => {
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(messages(logSpy)).toContain('No encrypted keys to rotate');
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the vault keys', async () => {
|
||||||
|
vaultKey('stage');
|
||||||
|
vaultKey('prod');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(question('key').choices).toEqual(['prod', 'stage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates the replacement into the tmp directory under the next name', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(argsOf('ssh-keygen')).toEqual([
|
||||||
|
'-t',
|
||||||
|
'ed25519',
|
||||||
|
'-f',
|
||||||
|
path.join(tmpDir, 'id_prod-2'),
|
||||||
|
'-C',
|
||||||
|
'me@host',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the rotated key untouched in the vault', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
// The whole point of rotating this way: both keys are in the vault, and the
|
||||||
|
// one that is deployed is byte for byte what it was.
|
||||||
|
expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.age'), 'utf-8')).toBe(
|
||||||
|
'ENCRYPTED prod'
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod-2', 'id_prod-2.age'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypts the replacement to the vault recipient', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(argsOf('age')).toEqual([
|
||||||
|
'-r',
|
||||||
|
PUBKEY,
|
||||||
|
'-o',
|
||||||
|
path.join(keysDir, 'prod-2', 'id_prod-2.age'),
|
||||||
|
path.join(tmpDir, 'id_prod-2'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the comment of the key being replaced', async () => {
|
||||||
|
vaultKey('prod', 'deploy@prod');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(question('identity').default).toBe('deploy@prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers no comment when the stored public key has none', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
fs.writeFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'ssh-ed25519 AAAAprod\n');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(question('identity').default).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates a key whose public half was never stored', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
fs.rmSync(path.join(keysDir, 'prod', 'id_prod.pub'));
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(question('identity').default).toBeUndefined();
|
||||||
|
// Reported rather than printed as a blank line, since the user needs it to
|
||||||
|
// know what to remove from the host afterwards.
|
||||||
|
expect(messages(logSpy)).toContain('none stored at');
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod-2', 'id_prod-2.age'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints both public keys and what to do with them', async () => {
|
||||||
|
vaultKey('prod', 'deploy@prod');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
const output = messages(logSpy);
|
||||||
|
expect(output).toContain('ssh-ed25519 AAAAprod deploy@prod');
|
||||||
|
expect(output).toContain('ssh-ed25519 NEWKEY me@host');
|
||||||
|
// Deploy-then-retire, in that order: the reverse locks you out.
|
||||||
|
expect(output).toContain('Add the replacement public key');
|
||||||
|
expect(output).toContain('retire');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a name taken by a plaintext key outside the vault', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
fs.writeFileSync(path.join(sshDir, 'id_prod-2'), 'PRIVATE');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
// Generating id_prod-2 would have refused, or worse asked ssh-keygen to
|
||||||
|
// overwrite a private key that is in use.
|
||||||
|
expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod-3'));
|
||||||
|
expect(fs.readFileSync(path.join(sshDir, 'id_prod-2'), 'utf-8')).toBe('PRIVATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a name taken by an earlier rotation still in tmp', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'id_prod-2'), 'PRIVATE');
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod-3'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests a 4096 bit key for rsa', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
answer({ key: 'prod', algorithm: 'rsa', identity: '' });
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(argsOf('ssh-keygen')?.slice(-2)).toEqual(['-b', '4096']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at a failure from ssh-keygen without touching the vault', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('ssh-keygen exploded'), { stderr: 'ssh-keygen exploded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(messages(errorSpy)).toContain('Error generating key');
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod-2'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a failure from age and says where the replacement is', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
|
if (binary === 'age') {
|
||||||
|
throw Object.assign(new Error('age exploded'), { stderr: 'age exploded' });
|
||||||
|
}
|
||||||
|
const keyPath = args[args.indexOf('-f') + 1];
|
||||||
|
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||||
|
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 NEWKEY me@host\n');
|
||||||
|
return { exitCode: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await rotateKey(sshDir, keysDir, tmpDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(messages(errorSpy)).toContain('Error encrypting the replacement');
|
||||||
|
expect(messages(errorSpy)).toContain(path.join(tmpDir, 'id_prod-2'));
|
||||||
|
// No summary: nothing was stored, so there is nothing to deploy yet.
|
||||||
|
expect(messages(logSpy)).not.toContain('Add the replacement public key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('retireKey', () => {
|
||||||
|
let root: string;
|
||||||
|
let sshDir: string;
|
||||||
|
let keysDir: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const vaultKey = (name: string) => {
|
||||||
|
const dir = path.join(keysDir, name);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`);
|
||||||
|
fs.writeFileSync(path.join(dir, `id_${name}.pub`), `PUBLIC ${name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const answer = (answers: Record<string, unknown>) => {
|
||||||
|
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||||
|
const { name } = questions[0];
|
||||||
|
return { [name]: answers[name] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const question = (name: string) =>
|
||||||
|
prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name);
|
||||||
|
|
||||||
|
const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-retire-')));
|
||||||
|
sshDir = path.join(root, '.ssh');
|
||||||
|
keysDir = path.join(root, 'vault', 'keys');
|
||||||
|
tmpDir = path.join(root, 'vault', 'tmp');
|
||||||
|
fs.mkdirSync(keysDir, { recursive: true });
|
||||||
|
fs.mkdirSync(sshDir, { recursive: true });
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
answer({ key: 'prod', confirmed: true, typed: 'prod' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says there is nothing to retire on an empty vault', async () => {
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(messages()).toContain('No encrypted keys in the vault');
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the vault entry and its directory', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('prod-2');
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
|
// Only the one that was named.
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod-2', 'id_prod-2.age'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the plaintext copies as well', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('prod-2');
|
||||||
|
for (const dir of [sshDir, tmpDir]) {
|
||||||
|
fs.writeFileSync(path.join(dir, 'id_prod'), 'PRIVATE');
|
||||||
|
fs.writeFileSync(path.join(dir, 'id_prod.pub'), 'PUBLIC');
|
||||||
|
}
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(fs.readdirSync(sshDir)).toEqual([]);
|
||||||
|
expect(fs.readdirSync(tmpDir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists every path before asking, and asks with a no default', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('prod-2');
|
||||||
|
fs.writeFileSync(path.join(sshDir, 'id_prod'), 'PRIVATE');
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(messages()).toContain(path.join(keysDir, 'prod', 'id_prod.age'));
|
||||||
|
expect(messages()).toContain(path.join(sshDir, 'id_prod'));
|
||||||
|
expect(question('confirmed')).toMatchObject({ type: 'confirm', default: false });
|
||||||
|
expect(question('confirmed').message).toContain('3 files');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts one file as one file', async () => {
|
||||||
|
vaultKey('prod-2');
|
||||||
|
fs.mkdirSync(path.join(keysDir, 'prod'));
|
||||||
|
fs.writeFileSync(path.join(keysDir, 'prod', 'id_prod.age'), 'ENCRYPTED');
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(question('confirmed').message).toContain('1 file?');
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what supersedes the key it is about to delete', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('prod-2');
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(messages()).toContain('prod-2 is in the vault and supersedes prod');
|
||||||
|
expect(question('typed')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps everything when the confirmation is declined', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('prod-2');
|
||||||
|
answer({ key: 'prod', confirmed: false });
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
|
||||||
|
expect(messages()).toContain('Nothing was deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a key nothing replaces', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultKey('prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns that this is the only copy', async () => {
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(messages()).toContain('Nothing in the vault supersedes prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for the name to be typed out, and deletes when it matches', async () => {
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
// A y/n is one keystroke from an irreversible deletion; this is not.
|
||||||
|
expect(question('typed')).toBeDefined();
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes nothing when the typed name does not match', async () => {
|
||||||
|
answer({ key: 'prod', confirmed: true, typed: 'prodd' });
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
|
||||||
|
expect(messages()).toContain('Name did not match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the name with stray whitespace', async () => {
|
||||||
|
answer({ key: 'prod', confirmed: true, typed: ' prod ' });
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a vault directory that holds something else', async () => {
|
||||||
|
vaultKey('prod');
|
||||||
|
vaultKey('prod-2');
|
||||||
|
fs.mkdirSync(path.join(keysDir, 'prod', 'notes'));
|
||||||
|
|
||||||
|
await retireKey(sshDir, keysDir, tmpDir);
|
||||||
|
|
||||||
|
// The .age and .pub are gone; the directory stays, and says why.
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'notes'))).toBe(true);
|
||||||
|
expect(messages()).toContain('it still holds other files');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Tests for runTool.
|
||||||
|
*
|
||||||
|
* These spawn real processes rather than mocking execa. What runTool exists for
|
||||||
|
* is the shape of an execa failure — a mock would assert only what this test
|
||||||
|
* already assumes. It lives apart from utils.test.ts, which mocks execa to test
|
||||||
|
* the callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { runTool, ToolNotFoundError } from '../src/keyman.utils.js';
|
||||||
|
|
||||||
|
describe('runTool', () => {
|
||||||
|
it('returns stdout on success', async () => {
|
||||||
|
const result = await runTool('node', ['-e', 'process.stdout.write("hi")']);
|
||||||
|
|
||||||
|
expect(result.stdout).toBe('hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes options through', async () => {
|
||||||
|
const result = await runTool('node', ['-e', 'process.stdout.write(process.env.PROBE ?? "")'], {
|
||||||
|
env: { PROBE: 'from-options' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.stdout).toBe('from-options');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports empty stdout when the output went elsewhere', async () => {
|
||||||
|
const result = await runTool('node', ['-e', 'process.stdout.write("hi")'], {
|
||||||
|
stdout: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.stdout).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a missing binary into an instruction rather than an ENOENT', async () => {
|
||||||
|
const failure = runTool('keyman-no-such-binary', []);
|
||||||
|
|
||||||
|
await expect(failure).rejects.toThrow(ToolNotFoundError);
|
||||||
|
await expect(failure).rejects.toThrow(
|
||||||
|
'`keyman-no-such-binary` was not found on PATH. Install it and try again.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces what the binary wrote to stderr', async () => {
|
||||||
|
await expect(
|
||||||
|
runTool('node', ['-e', 'process.stderr.write("no recipient\\n"); process.exit(1)'])
|
||||||
|
).rejects.toThrow('`node` failed: no recipient');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the command summary when stderr is empty', async () => {
|
||||||
|
await expect(runTool('node', ['-e', 'process.exit(3)'])).rejects.toThrow(
|
||||||
|
/`node` failed: .*exit code 3/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
/**
|
||||||
|
* Tests for keyman.update module
|
||||||
|
*
|
||||||
|
* Every network call, clock read and spawn is injected, so nothing here
|
||||||
|
* reaches a registry or the user's home directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildSelfUpdateCommand,
|
||||||
|
type Channel,
|
||||||
|
channelForVersion,
|
||||||
|
checkForUpdate,
|
||||||
|
detectPackageManager,
|
||||||
|
fetchChannelVersion,
|
||||||
|
formatCommand,
|
||||||
|
formatUpdateNotice,
|
||||||
|
getUpdateCachePath,
|
||||||
|
isUpdateCheckDisabled,
|
||||||
|
NPMJS_REGISTRY,
|
||||||
|
normalizeRegistry,
|
||||||
|
readUpdateCache,
|
||||||
|
resolveRegistry,
|
||||||
|
selfUpdate,
|
||||||
|
type UpdateCache,
|
||||||
|
updateNotice,
|
||||||
|
writeUpdateCache,
|
||||||
|
} from '../src/keyman.update.js';
|
||||||
|
|
||||||
|
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let cachePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-update-'));
|
||||||
|
cachePath = path.join(tmpDir, 'update-check.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fetch stand-in returning the given dist-tags */
|
||||||
|
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
|
||||||
|
return (async () =>
|
||||||
|
({
|
||||||
|
ok,
|
||||||
|
json: async () => ({ 'dist-tags': distTags }),
|
||||||
|
}) as Response) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('channelForVersion', () => {
|
||||||
|
it('maps a clean release to latest', () => {
|
||||||
|
expect(channelForVersion('0.5.0')).toBe('latest');
|
||||||
|
expect(channelForVersion('1.2.3')).toBe('latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a snapshot to main', () => {
|
||||||
|
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps any other prerelease to next', () => {
|
||||||
|
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
|
||||||
|
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an unparseable version as latest', () => {
|
||||||
|
expect(channelForVersion('not-a-version')).toBe('latest');
|
||||||
|
expect(channelForVersion('')).toBe('latest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeRegistry', () => {
|
||||||
|
it('adds a trailing slash', () => {
|
||||||
|
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an existing trailing slash alone', () => {
|
||||||
|
expect(normalizeRegistry(GITEA)).toBe(GITEA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRegistry', () => {
|
||||||
|
it('prefers the KEYMAN_REGISTRY override', async () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const registry = await resolveRegistry({
|
||||||
|
env: { KEYMAN_REGISTRY: 'https://example.com/npm' },
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
expect(registry).toBe('https://example.com/npm/');
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to npm config', async () => {
|
||||||
|
const run = vi.fn(async () => GITEA);
|
||||||
|
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
|
||||||
|
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats npm printing "undefined" as unset', async () => {
|
||||||
|
const run = vi.fn(async () => 'undefined');
|
||||||
|
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats npm printing "null" as unset', async () => {
|
||||||
|
const run = vi.fn(async () => 'null');
|
||||||
|
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty output as unset', async () => {
|
||||||
|
const run = vi.fn(async () => ' ');
|
||||||
|
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to npmjs when npm is missing', async () => {
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
throw new Error('ENOENT');
|
||||||
|
});
|
||||||
|
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a blank override', async () => {
|
||||||
|
const run = vi.fn(async () => GITEA);
|
||||||
|
expect(await resolveRegistry({ env: { KEYMAN_REGISTRY: ' ' }, run })).toBe(GITEA);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchChannelVersion', () => {
|
||||||
|
it('reads the requested dist-tag', async () => {
|
||||||
|
const version = await fetchChannelVersion({
|
||||||
|
registry: GITEA,
|
||||||
|
channel: 'main',
|
||||||
|
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
|
||||||
|
});
|
||||||
|
expect(version).toBe('0.5.0-main.14.gabc1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the tag does not exist', async () => {
|
||||||
|
const version = await fetchChannelVersion({
|
||||||
|
registry: GITEA,
|
||||||
|
channel: 'latest',
|
||||||
|
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
|
||||||
|
});
|
||||||
|
expect(version).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on a non-ok response', async () => {
|
||||||
|
const version = await fetchChannelVersion({
|
||||||
|
registry: GITEA,
|
||||||
|
channel: 'latest',
|
||||||
|
fetchImpl: fakeFetch({}, false),
|
||||||
|
});
|
||||||
|
expect(version).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the packument has no dist-tags at all', async () => {
|
||||||
|
const fetchImpl = (async () =>
|
||||||
|
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
|
||||||
|
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('url-encodes the scoped package name onto the registry', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const fetchImpl = (async (url: string) => {
|
||||||
|
seen.push(url);
|
||||||
|
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
// No trailing slash on purpose: it must be normalised before joining.
|
||||||
|
await fetchChannelVersion({
|
||||||
|
registry: 'https://example.com/npm',
|
||||||
|
channel: 'latest',
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fkeyman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a bearer token when one is given', async () => {
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||||
|
headers = init.headers as Record<string, string>;
|
||||||
|
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
|
||||||
|
expect(headers.authorization).toBe('Bearer secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the authorization header when no token is given', async () => {
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||||
|
headers = init.headers as Record<string, string>;
|
||||||
|
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
|
||||||
|
expect(headers.authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the update cache', () => {
|
||||||
|
it('round-trips', () => {
|
||||||
|
const cache: UpdateCache = {
|
||||||
|
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.6.0',
|
||||||
|
};
|
||||||
|
writeUpdateCache(cache, cachePath);
|
||||||
|
expect(readUpdateCache(cachePath)).toEqual(cache);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the containing directory', () => {
|
||||||
|
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: null,
|
||||||
|
},
|
||||||
|
nested
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(nested)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a missing file as null', () => {
|
||||||
|
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads malformed JSON as null', () => {
|
||||||
|
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
|
||||||
|
expect(readUpdateCache(cachePath)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a file without a checkedAt stamp', () => {
|
||||||
|
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
|
||||||
|
expect(readUpdateCache(cachePath)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows a write it cannot perform', () => {
|
||||||
|
// A path whose parent is a file, not a directory.
|
||||||
|
const blocked = path.join(cachePath, 'nested.json');
|
||||||
|
fs.writeFileSync(cachePath, '{}', 'utf-8');
|
||||||
|
expect(() =>
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: null,
|
||||||
|
},
|
||||||
|
blocked
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to a path under the home directory', () => {
|
||||||
|
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.keyman/update-check.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isUpdateCheckDisabled', () => {
|
||||||
|
it('is off by default', () => {
|
||||||
|
expect(isUpdateCheckDisabled({})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours KEYMAN_NO_UPDATE_CHECK', () => {
|
||||||
|
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '1' })).toBe(true);
|
||||||
|
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'yes' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats 0 and false as not disabled', () => {
|
||||||
|
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '0' })).toBe(false);
|
||||||
|
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'false' })).toBe(false);
|
||||||
|
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables itself in CI', () => {
|
||||||
|
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkForUpdate', () => {
|
||||||
|
const base = {
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
env: {} as NodeJS.ProcessEnv,
|
||||||
|
now: Date.parse('2026-07-29T12:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reports a newer version on the channel', async () => {
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status).toMatchObject({
|
||||||
|
current: '0.5.0',
|
||||||
|
latest: '0.6.0',
|
||||||
|
channel: 'latest',
|
||||||
|
updateAvailable: true,
|
||||||
|
fromCache: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no update when the channel matches', async () => {
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||||
|
});
|
||||||
|
expect(status.updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat an older published version as an update', async () => {
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.4.0' }),
|
||||||
|
});
|
||||||
|
expect(status.updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the channel from the running version', async () => {
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
currentVersion: '0.5.0-main.13.gabc1234',
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
|
||||||
|
});
|
||||||
|
expect(status.channel).toBe('main');
|
||||||
|
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
|
||||||
|
expect(status.updateAvailable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes what it found to the cache', async () => {
|
||||||
|
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
|
||||||
|
expect(readUpdateCache(cachePath)).toEqual({
|
||||||
|
checkedAt: '2026-07-29T12:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.6.0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers from a fresh cache without touching the network', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.7.0',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.7.0');
|
||||||
|
expect(status.fromCache).toBe(true);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches once the cache goes stale', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-27T11:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.7.0',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.8.0' }),
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.8.0');
|
||||||
|
expect(status.fromCache).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a cache written for a different channel', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||||
|
channel: 'next',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '9.9.9',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.6.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a cache written for a different registry', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: GITEA,
|
||||||
|
latest: '9.9.9',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.6.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a cache stamped in the future', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2027-01-01T00:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '9.9.9',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.6.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a cache with an unparseable stamp', async () => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
cachePath,
|
||||||
|
JSON.stringify({
|
||||||
|
checkedAt: 'whenever',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '9.9.9',
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.6.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches when forced, even with a fresh cache', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.7.0',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
force: true,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.9.0' }),
|
||||||
|
});
|
||||||
|
expect(status.latest).toBe('0.9.0');
|
||||||
|
expect(status.fromCache).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the cached answer when the network fails', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: '2026-07-20T11:00:00.000Z',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.7.0',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const fetchImpl = (async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
|
||||||
|
expect(status.latest).toBe('0.7.0');
|
||||||
|
expect(status.updateAvailable).toBe(true);
|
||||||
|
expect(status.fromCache).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing when the network fails and no cache applies', async () => {
|
||||||
|
const fetchImpl = (async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
|
||||||
|
expect(status.latest).toBeNull();
|
||||||
|
expect(status.updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the registry when none is given', async () => {
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
cachePath,
|
||||||
|
env: {},
|
||||||
|
run: async () => GITEA,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status.registry).toBe(GITEA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a registry token from the environment through', async () => {
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||||
|
headers = init.headers as Record<string, string>;
|
||||||
|
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
env: { KEYMAN_REGISTRY_TOKEN: 'tok' },
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
expect(headers.authorization).toBe('Bearer tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not compare against an unparseable current version', async () => {
|
||||||
|
const status = await checkForUpdate({
|
||||||
|
...base,
|
||||||
|
currentVersion: 'dev',
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(status.updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectPackageManager', () => {
|
||||||
|
it('honours the environment override', () => {
|
||||||
|
expect(
|
||||||
|
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
|
||||||
|
).toBe('pnpm');
|
||||||
|
expect(
|
||||||
|
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
|
||||||
|
).toBe('yarn');
|
||||||
|
expect(
|
||||||
|
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
|
||||||
|
).toBe('bun');
|
||||||
|
expect(
|
||||||
|
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
|
||||||
|
).toBe('npm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an unrecognised override', () => {
|
||||||
|
expect(
|
||||||
|
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
|
||||||
|
).toBe('npm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises a pnpm global install', () => {
|
||||||
|
expect(
|
||||||
|
detectPackageManager({
|
||||||
|
env: {},
|
||||||
|
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/keyman',
|
||||||
|
})
|
||||||
|
).toBe('pnpm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises a bun global install', () => {
|
||||||
|
expect(
|
||||||
|
detectPackageManager({
|
||||||
|
env: {},
|
||||||
|
execPath: '/Users/x/.bun/install/global/node_modules/keyman',
|
||||||
|
})
|
||||||
|
).toBe('bun');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises a yarn global install', () => {
|
||||||
|
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/keyman' })).toBe('yarn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to npm', () => {
|
||||||
|
expect(
|
||||||
|
detectPackageManager({
|
||||||
|
env: {},
|
||||||
|
execPath: '/usr/local/lib/node_modules/@bitsquare/keyman/dist/keyman.cli.js',
|
||||||
|
})
|
||||||
|
).toBe('npm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a windows-style path and an empty path', () => {
|
||||||
|
expect(
|
||||||
|
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\keyman.exe' })
|
||||||
|
).toBe('pnpm');
|
||||||
|
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSelfUpdateCommand', () => {
|
||||||
|
it('builds an npm global install without a registry flag for npmjs', () => {
|
||||||
|
const command = buildSelfUpdateCommand({
|
||||||
|
packageManager: 'npm',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
});
|
||||||
|
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a scoped registry override for a non-npmjs registry', () => {
|
||||||
|
const command = buildSelfUpdateCommand({
|
||||||
|
packageManager: 'npm',
|
||||||
|
channel: 'main',
|
||||||
|
registry: GITEA,
|
||||||
|
});
|
||||||
|
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
|
||||||
|
// dependencies have to keep resolving from npmjs.
|
||||||
|
expect(formatCommand(command)).toBe(
|
||||||
|
`npm install --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
|
||||||
|
);
|
||||||
|
expect(command.args).not.toContain('--registry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises a registry given without a trailing slash', () => {
|
||||||
|
const command = buildSelfUpdateCommand({
|
||||||
|
packageManager: 'npm',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: 'https://registry.npmjs.org',
|
||||||
|
});
|
||||||
|
expect(command.args).toEqual(['install', '--global', '@bitsquare/keyman@latest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds for pnpm, yarn and bun', () => {
|
||||||
|
expect(
|
||||||
|
formatCommand(
|
||||||
|
buildSelfUpdateCommand({
|
||||||
|
packageManager: 'pnpm',
|
||||||
|
channel: 'next',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe('pnpm add --global @bitsquare/keyman@next');
|
||||||
|
expect(
|
||||||
|
formatCommand(
|
||||||
|
buildSelfUpdateCommand({
|
||||||
|
packageManager: 'yarn',
|
||||||
|
channel: 'next',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe('yarn global add @bitsquare/keyman@next');
|
||||||
|
expect(
|
||||||
|
formatCommand(
|
||||||
|
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
|
||||||
|
)
|
||||||
|
).toBe('bun add --global @bitsquare/keyman@next');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an explicit package name', () => {
|
||||||
|
const command = buildSelfUpdateCommand({
|
||||||
|
packageManager: 'npm',
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
packageName: '@bitsquare/nopy',
|
||||||
|
});
|
||||||
|
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUpdateNotice', () => {
|
||||||
|
const status = {
|
||||||
|
current: '0.5.0',
|
||||||
|
latest: '0.6.0',
|
||||||
|
channel: 'latest' as Channel,
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
updateAvailable: true,
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('names both versions and the command', () => {
|
||||||
|
const notice = formatUpdateNotice(status, 'npm');
|
||||||
|
expect(notice).toContain('0.5.0 -> 0.6.0');
|
||||||
|
expect(notice).toContain('keyman self-update');
|
||||||
|
expect(notice).toContain('npm install --global @bitsquare/keyman@latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a non-default channel', () => {
|
||||||
|
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when there is no update', () => {
|
||||||
|
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when the latest version is unknown', () => {
|
||||||
|
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects the package manager when none is given', () => {
|
||||||
|
expect(formatUpdateNotice(status)).toContain('@bitsquare/keyman@latest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateNotice', () => {
|
||||||
|
it('returns a notice when an update exists', async () => {
|
||||||
|
const notice = await updateNotice({
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY },
|
||||||
|
cachePath,
|
||||||
|
now: Date.parse('2026-07-29T12:00:00.000Z'),
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
});
|
||||||
|
expect(notice).toContain('0.5.0 -> 0.6.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the check is disabled', async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const notice = await updateNotice({
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
env: { KEYMAN_NO_UPDATE_CHECK: '1' },
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
expect(notice).toBeNull();
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null rather than throwing when everything fails', async () => {
|
||||||
|
const notice = await updateNotice({
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
env: {},
|
||||||
|
cachePath,
|
||||||
|
run: async () => {
|
||||||
|
throw new Error('no npm');
|
||||||
|
},
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
expect(notice).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selfUpdate', () => {
|
||||||
|
const base = {
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
|
||||||
|
packageManager: 'npm' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('runs the install when a newer version exists', async () => {
|
||||||
|
const spawn = vi.fn(async () => undefined);
|
||||||
|
const result = await selfUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
spawn,
|
||||||
|
});
|
||||||
|
expect(result.ran).toBe(true);
|
||||||
|
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/keyman@latest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when already up to date', async () => {
|
||||||
|
const spawn = vi.fn(async () => undefined);
|
||||||
|
const result = await selfUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||||
|
spawn,
|
||||||
|
});
|
||||||
|
expect(result.ran).toBe(false);
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reinstalls when forced', async () => {
|
||||||
|
const spawn = vi.fn(async () => undefined);
|
||||||
|
const result = await selfUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
force: true,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||||
|
spawn,
|
||||||
|
});
|
||||||
|
expect(result.ran).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the command without running it on a dry run', async () => {
|
||||||
|
const spawn = vi.fn(async () => undefined);
|
||||||
|
const result = await selfUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
dryRun: true,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
spawn,
|
||||||
|
});
|
||||||
|
expect(result.ran).toBe(false);
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/keyman@latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a fresh cache, because the user asked', async () => {
|
||||||
|
writeUpdateCache(
|
||||||
|
{
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
channel: 'latest',
|
||||||
|
registry: NPMJS_REGISTRY,
|
||||||
|
latest: '0.5.0',
|
||||||
|
},
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
const result = await selfUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||||
|
spawn: async () => undefined,
|
||||||
|
});
|
||||||
|
expect(result.status.latest).toBe('0.6.0');
|
||||||
|
expect(result.ran).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows an explicit channel and registry', async () => {
|
||||||
|
const result = await selfUpdate({
|
||||||
|
currentVersion: '0.5.0',
|
||||||
|
env: {},
|
||||||
|
packageManager: 'pnpm',
|
||||||
|
channel: 'main',
|
||||||
|
registry: GITEA,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
|
||||||
|
spawn: async () => undefined,
|
||||||
|
});
|
||||||
|
expect(formatCommand(result.command)).toBe(
|
||||||
|
`pnpm add --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run when the registry could not be reached', async () => {
|
||||||
|
const spawn = vi.fn(async () => undefined);
|
||||||
|
const result = await selfUpdate({
|
||||||
|
...base,
|
||||||
|
cachePath,
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
}) as unknown as typeof fetch,
|
||||||
|
spawn,
|
||||||
|
});
|
||||||
|
expect(result.status.latest).toBeNull();
|
||||||
|
expect(result.ran).toBe(false);
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for extractAgePublicKey.
|
* Tests for extractAgePublicKey.
|
||||||
*
|
*
|
||||||
* Runs against real files in a temp directory: the function is a thin wrapper
|
* Real files in a temp directory, but a mocked execa: the recipient is now
|
||||||
* around fs plus a regex, and faking fs would only test the fake.
|
* derived by spawning `age-keygen -y`, and the gate cannot depend on age being
|
||||||
|
* installed on the machine running it. runTool itself is tested against real
|
||||||
|
* processes in tool.test.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { execa } = vi.hoisted(() => ({ execa: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('execa', () => ({ execa }));
|
||||||
|
|
||||||
import { extractAgePublicKey } from '../src/keyman.utils.js';
|
import { extractAgePublicKey } from '../src/keyman.utils.js';
|
||||||
|
|
||||||
|
const DERIVED = 'age1derivedfromthesecretkey';
|
||||||
|
const IN_COMMENT = 'age1fromthecomment';
|
||||||
|
|
||||||
describe('extractAgePublicKey', () => {
|
describe('extractAgePublicKey', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
const keyFile = (contents: string) => {
|
const keyFile = (contents: string) => {
|
||||||
const file = path.join(tmpDir, 'age.key');
|
const file = path.join(tmpDir, 'age.key');
|
||||||
@@ -21,9 +32,33 @@ describe('extractAgePublicKey', () => {
|
|||||||
return file;
|
return file;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A well-formed identity file, whose comment can be made to disagree */
|
||||||
|
const identity = (comment = DERIVED) =>
|
||||||
|
keyFile(
|
||||||
|
['# created: 2026-01-01T00:00:00Z', `# public key: ${comment}`, 'AGE-SECRET-KEY-1QQQ'].join(
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes age-keygen unavailable, the one case that falls back to the comment.
|
||||||
|
*
|
||||||
|
* Throws from an implementation rather than using mockRejectedValue: that
|
||||||
|
* builds its rejected promise when the mock is configured, and configuring it
|
||||||
|
* in a beforeEach leaves the rejection unhandled for a tick.
|
||||||
|
*/
|
||||||
|
const noAgeKeygen = () => {
|
||||||
|
execa.mockImplementation(async () => {
|
||||||
|
throw Object.assign(new Error('spawn age-keygen ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-utils-')));
|
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-utils-')));
|
||||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
execa.mockResolvedValue({ stdout: `${DERIVED}\n` });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -31,49 +66,82 @@ describe('extractAgePublicKey', () => {
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the public key from a standard age key file', () => {
|
it('derives the recipient from the secret key with age-keygen', async () => {
|
||||||
const file = keyFile(
|
const file = identity();
|
||||||
[
|
|
||||||
'# created: 2026-01-01T00:00:00Z',
|
|
||||||
'# public key: age1abc123xyz',
|
|
||||||
'AGE-SECRET-KEY-1QQQ',
|
|
||||||
].join('\n')
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(extractAgePublicKey(file)).toBe('age1abc123xyz');
|
await expect(extractAgePublicKey(file)).resolves.toBe(DERIVED);
|
||||||
|
expect(execa).toHaveBeenCalledWith('age-keygen', ['-y', file]);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates extra whitespace after the label', () => {
|
it('prefers the derived key over a comment that disagrees', async () => {
|
||||||
const file = keyFile('# public key: age1spaced\n');
|
// §2.3: the comment is editable text, and this is what makes it not matter.
|
||||||
|
const file = identity('age1staleorforged');
|
||||||
|
|
||||||
expect(extractAgePublicKey(file)).toBe('age1spaced');
|
await expect(extractAgePublicKey(file)).resolves.toBe(DERIVED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null and reports when the file does not exist', () => {
|
it('returns null and reports when the file does not exist', async () => {
|
||||||
const missing = path.join(tmpDir, 'nope.key');
|
const missing = path.join(tmpDir, 'nope.key');
|
||||||
|
|
||||||
expect(extractAgePublicKey(missing)).toBeNull();
|
await expect(extractAgePublicKey(missing)).resolves.toBeNull();
|
||||||
expect(errorSpy.mock.calls[0][0]).toContain('Age key file not found');
|
expect(errorSpy.mock.calls[0][0]).toContain('Age key file not found');
|
||||||
|
expect(execa).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when the file has no public key line', () => {
|
it('returns null when age-keygen refuses the file, without trusting the comment', async () => {
|
||||||
const file = keyFile('AGE-SECRET-KEY-1QQQ\n');
|
const file = identity(IN_COMMENT);
|
||||||
|
execa.mockRejectedValue(
|
||||||
|
Object.assign(new Error('failed'), { exitCode: 1, stderr: 'age-keygen: error: malformed' })
|
||||||
|
);
|
||||||
|
|
||||||
expect(extractAgePublicKey(file)).toBeNull();
|
await expect(extractAgePublicKey(file)).resolves.toBeNull();
|
||||||
expect(errorSpy).not.toHaveBeenCalled();
|
expect(errorSpy.mock.calls[0][0]).toContain('malformed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a key that is not on its own line', () => {
|
it('returns null when age-keygen prints something that is not a recipient', async () => {
|
||||||
const file = keyFile('prefix # public key: age1inline\n');
|
const file = identity();
|
||||||
|
execa.mockResolvedValue({ stdout: 'Public key: (none)\n' });
|
||||||
|
|
||||||
expect(extractAgePublicKey(file)).toBeNull();
|
await expect(extractAgePublicKey(file)).resolves.toBeNull();
|
||||||
|
expect(errorSpy.mock.calls[0][0]).toContain('derived no public key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null and reports when the file cannot be read', () => {
|
describe('without age-keygen installed', () => {
|
||||||
const asDirectory = path.join(tmpDir, 'age.key');
|
beforeEach(noAgeKeygen);
|
||||||
fs.mkdirSync(asDirectory);
|
|
||||||
|
|
||||||
expect(extractAgePublicKey(asDirectory)).toBeNull();
|
it('falls back to the comment, warning that it is unverified', async () => {
|
||||||
expect(errorSpy.mock.calls[0][0]).toContain('Failed to read key file');
|
const file = identity(IN_COMMENT);
|
||||||
|
|
||||||
|
await expect(extractAgePublicKey(file)).resolves.toBe(IN_COMMENT);
|
||||||
|
expect(warnSpy.mock.calls[0][0]).toContain('unverified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates extra whitespace after the label', async () => {
|
||||||
|
const file = keyFile('# public key: age1spaced\n');
|
||||||
|
|
||||||
|
await expect(extractAgePublicKey(file)).resolves.toBe('age1spaced');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the file has no public key line', async () => {
|
||||||
|
const file = keyFile('AGE-SECRET-KEY-1QQQ\n');
|
||||||
|
|
||||||
|
await expect(extractAgePublicKey(file)).resolves.toBeNull();
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a key that is not on its own line', async () => {
|
||||||
|
const file = keyFile('prefix # public key: age1inline\n');
|
||||||
|
|
||||||
|
await expect(extractAgePublicKey(file)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and reports when the file cannot be read', async () => {
|
||||||
|
const asDirectory = path.join(tmpDir, 'age.key');
|
||||||
|
fs.mkdirSync(asDirectory);
|
||||||
|
|
||||||
|
await expect(extractAgePublicKey(asDirectory)).resolves.toBeNull();
|
||||||
|
expect(errorSpy.mock.calls[0][0]).toContain('Failed to read key file');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* End-to-end over the configured vault layout.
|
||||||
|
*
|
||||||
|
* Everything except `age` and the prompts is real here — the config loader, the
|
||||||
|
* path resolution, encrypt and list all run — because the bug this covers lived
|
||||||
|
* in the seam between them: encrypt wrote to `vaultRoot` while list read from
|
||||||
|
* `keysDir`, so with the defaults (`keysDir: 'keys'`) an encrypted key was
|
||||||
|
* invisible to the very next listing. Every unit suite passed throughout, since
|
||||||
|
* each was told which directory to use.
|
||||||
|
*
|
||||||
|
* Non-default names on purpose: `keys`/`tmp` would also pass against a function
|
||||||
|
* that ignored the config entirely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('execa', () => ({ execa }));
|
||||||
|
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||||
|
|
||||||
|
import { keyman } from '../src/keyman.main.js';
|
||||||
|
|
||||||
|
describe('the configured vault layout', () => {
|
||||||
|
let root: string;
|
||||||
|
let project: string;
|
||||||
|
let home: string;
|
||||||
|
let sshDir: string;
|
||||||
|
let vaultRoot: string;
|
||||||
|
let cwd: string;
|
||||||
|
let env: NodeJS.ProcessEnv;
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
/** The one line of the listing table describing `name`. */
|
||||||
|
const listingRow = (name: string) =>
|
||||||
|
output()
|
||||||
|
.split('\n')
|
||||||
|
.find((line) => line.includes(name) && line.includes('['));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cwd = process.cwd();
|
||||||
|
env = { ...process.env };
|
||||||
|
|
||||||
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-layout-')));
|
||||||
|
project = path.join(root, 'project');
|
||||||
|
home = path.join(root, 'home');
|
||||||
|
sshDir = path.join(home, '.ssh');
|
||||||
|
vaultRoot = path.join(project, 'vault');
|
||||||
|
|
||||||
|
fs.mkdirSync(project, { recursive: true });
|
||||||
|
fs.mkdirSync(sshDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(sshDir, 'id_prod'), 'PRIVATE');
|
||||||
|
fs.writeFileSync(path.join(sshDir, 'id_prod.pub'), 'PUBLIC');
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(project, '.keymanrc.json'),
|
||||||
|
JSON.stringify({ vaultRoot: 'vault', keysDir: 'encrypted', tmpDir: 'plain' })
|
||||||
|
);
|
||||||
|
|
||||||
|
// HOME also redirects os.homedir(), so the real ~/.keymanrc.json cannot
|
||||||
|
// reach the loader and make this test depend on the machine it runs on.
|
||||||
|
process.env.HOME = home;
|
||||||
|
delete process.env.VAULT_ROOT;
|
||||||
|
process.chdir(project);
|
||||||
|
|
||||||
|
// The age identity has to exist before extractAgePublicKey will shell out.
|
||||||
|
fs.mkdirSync(vaultRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(vaultRoot, 'age.key'), 'AGE-SECRET-KEY-1');
|
||||||
|
|
||||||
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
|
if (binary === 'age-keygen') {
|
||||||
|
return { stdout: 'age1recipient' };
|
||||||
|
}
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.chdir(cwd);
|
||||||
|
process.env = env;
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Walks the menu, answering encrypt's key selection along the way. */
|
||||||
|
const run = (categories: string[]) => {
|
||||||
|
const queue = [...categories, 'quit'];
|
||||||
|
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||||
|
switch (questions[0].name) {
|
||||||
|
case 'user':
|
||||||
|
return { user: '@current' };
|
||||||
|
case 'selectedKeys':
|
||||||
|
return { selectedKeys: ['id_prod'] };
|
||||||
|
default:
|
||||||
|
return { category: queue.shift() };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return keyman();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('encrypts into the configured keys directory, where the listing looks', async () => {
|
||||||
|
await run(['encrypt', 'list']);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, 'encrypted', 'prod', 'id_prod.age'))).toBe(true);
|
||||||
|
// ✅ is reachable only via inVault && inSsh, and the columns are
|
||||||
|
// [vault] [tmp] [.ssh] — either alone would pass on a blank vault column.
|
||||||
|
expect(listingRow('id_prod')).toContain('✅');
|
||||||
|
expect(listingRow('id_prod')).toMatch(/\[✓]\s+\[ ]\s+\[✓]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the configured directory names for every path it prints', async () => {
|
||||||
|
await run([]);
|
||||||
|
|
||||||
|
expect(output()).toContain(path.join(vaultRoot, 'encrypted'));
|
||||||
|
expect(output()).toContain(path.join(vaultRoot, 'plain'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Tests for storeInVault, the write path encrypt and generate share.
|
||||||
|
*
|
||||||
|
* Its ordinary use is covered through those two callers; what is here is the
|
||||||
|
* behaviour that is awkward to reach from either — an ssh-keygen that succeeds
|
||||||
|
* without printing anything, and a failure over an entry that already exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { execa } = vi.hoisted(() => ({ execa: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('execa', () => ({ execa }));
|
||||||
|
|
||||||
|
import { listVaultKeys, storeInVault } from '../src/keyman.vault.js';
|
||||||
|
|
||||||
|
describe('listVaultKeys', () => {
|
||||||
|
let keysDir: string;
|
||||||
|
|
||||||
|
const entry = (name: string, file = `id_${name}.age`) => {
|
||||||
|
fs.mkdirSync(path.join(keysDir, name), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(keysDir, name, file), 'ENCRYPTED');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
keysDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-vaultlist-')));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(keysDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty for a keys directory that was never created', () => {
|
||||||
|
expect(listVaultKeys(path.join(keysDir, 'nope'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts the entries rather than taking the filesystem order', () => {
|
||||||
|
for (const name of ['stage', 'alpha', 'prod']) {
|
||||||
|
entry(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(listVaultKeys(keysDir)).toEqual(['alpha', 'prod', 'stage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a directory with no encrypted key in it', () => {
|
||||||
|
entry('prod');
|
||||||
|
// The shape a failed encryption used to leave behind, and a plain mistake.
|
||||||
|
fs.mkdirSync(path.join(keysDir, 'empty'));
|
||||||
|
entry('notes', 'README.md');
|
||||||
|
|
||||||
|
expect(listVaultKeys(keysDir)).toEqual(['prod']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a loose file', () => {
|
||||||
|
entry('prod');
|
||||||
|
fs.writeFileSync(path.join(keysDir, 'id_stage.age'), 'ENCRYPTED');
|
||||||
|
|
||||||
|
expect(listVaultKeys(keysDir)).toEqual(['prod']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storeInVault', () => {
|
||||||
|
let root: string;
|
||||||
|
let keysDir: string;
|
||||||
|
let keyPath: string;
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const PUBKEY = 'age1recipient';
|
||||||
|
|
||||||
|
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||||
|
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-vault-')));
|
||||||
|
keysDir = path.join(root, 'keys');
|
||||||
|
keyPath = path.join(root, 'id_prod');
|
||||||
|
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when ssh-keygen succeeds but prints no key', async () => {
|
||||||
|
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||||
|
if (binary === 'ssh-keygen') return { stdout: ' \n' };
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await storeInVault(keyPath, keysDir, PUBKEY);
|
||||||
|
|
||||||
|
// An exit code of 0 is not a public key: writing a .pub holding whitespace
|
||||||
|
// would put a file in the vault that no host would ever accept.
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false);
|
||||||
|
expect(messages(warnSpy)).toContain('no public key could be derived');
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the public half at the same time as the encrypted key', async () => {
|
||||||
|
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA sibling');
|
||||||
|
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const vaultPath = await storeInVault(keyPath, keysDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(vaultPath).toBe(path.join(keysDir, 'prod'));
|
||||||
|
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe(
|
||||||
|
'ssh-ed25519 AAAA sibling'
|
||||||
|
);
|
||||||
|
// No ssh-keygen: the sibling was there, so nothing needed deriving.
|
||||||
|
expect(execa.mock.calls.every((c) => c[0] === 'age')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the vault entry private to the owner', async () => {
|
||||||
|
fs.writeFileSync(`${keyPath}.pub`, 'PUBLIC');
|
||||||
|
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||||
|
return { stdout: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await storeInVault(keyPath, keysDir, PUBKEY);
|
||||||
|
|
||||||
|
expect(fs.statSync(path.join(keysDir, 'prod')).mode & 0o777).toBe(0o700);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when age fails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.writeFileSync(`${keyPath}.pub`, 'PUBLIC');
|
||||||
|
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||||
|
// Half-written output, the way a failing age can leave it.
|
||||||
|
fs.writeFileSync(args[args.indexOf('-o') + 1], 'TRUNC');
|
||||||
|
throw Object.assign(new Error('age refused'), { stderr: 'no recipient' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no truncated key behind for decrypt to offer', async () => {
|
||||||
|
await expect(storeInVault(keyPath, keysDir, PUBKEY)).rejects.toThrow('`age` failed');
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an entry that was already there', async () => {
|
||||||
|
const vaultPath = path.join(keysDir, 'prod');
|
||||||
|
fs.mkdirSync(vaultPath, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(vaultPath, 'id_prod.pub'), 'THE OLD PUBLIC KEY');
|
||||||
|
|
||||||
|
await expect(storeInVault(keyPath, keysDir, PUBKEY)).rejects.toThrow('`age` failed');
|
||||||
|
|
||||||
|
// Cleaning up after a failure must not take the previous key with it.
|
||||||
|
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe(
|
||||||
|
'THE OLD PUBLIC KEY'
|
||||||
|
);
|
||||||
|
expect(messages(logSpy)).not.toContain('Encrypted and stored');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 bitsquare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# @bitsquare/nopy-cubes-core
|
||||||
|
|
||||||
|
The core cube bundle for [nopy](https://www.npmjs.com/package/@bitsquare/nopy):
|
||||||
|
base packages, users, SSH, firewalling, networking, web serving and runtimes.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add -D @bitsquare/nopy-cubes-core
|
||||||
|
```
|
||||||
|
|
||||||
|
Then name it in `.nopyrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hosts": ["web-1"],
|
||||||
|
"cubePackages": ["@bitsquare/nopy-cubes-core"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`nopy` resolves the package from the directory of the config file that named it
|
||||||
|
and scans its `cubes/` directory exactly as it scans a `cubeDirs` entry. Nothing
|
||||||
|
has to be linked or copied.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
| Area | Cube ids |
|
||||||
|
| ---------- | ------------------------------------------------------------------- |
|
||||||
|
| admin | `admin:cockpit`, `admin:hostname`, `admin:locale` |
|
||||||
|
| packages | `apt:essentials`, `apt:install` |
|
||||||
|
| hardening | `armor:fail2ban`, `armor:ssh`, `armor:ufw` |
|
||||||
|
| web | `caddy`, `caddy:spa` |
|
||||||
|
| source | `git:clone` |
|
||||||
|
| networking | `net:tailscale`, `net:wifi:access-point`, `net:wifi:connection` |
|
||||||
|
| runtimes | `runtime:docker`, `runtime:nodevm` |
|
||||||
|
| services | `service:autostart` |
|
||||||
|
| ssh | `ssh:authorize`, `ssh:keygen`, `ssh:keyman` |
|
||||||
|
| users | `user:add`, `user:edit` |
|
||||||
|
|
||||||
|
Run `nopy` and pick from the list, or `nopy -P` to print the pyinfra commands
|
||||||
|
without executing them. Each cube directory has its own `README.md`.
|
||||||
|
|
||||||
|
## Cube ids are global
|
||||||
|
|
||||||
|
An id such as `apt:essentials` is claimed repo-wide, not per bundle: two cubes
|
||||||
|
with the same id — whichever sources they came from — abort the run with an
|
||||||
|
error naming both. Prefix your own cubes distinctly if you also point
|
||||||
|
`cubeDirs` at a local tree.
|
||||||
|
|
||||||
|
## The bundle is read-only
|
||||||
|
|
||||||
|
Under pnpm the installed files are hardlinked into the global store, so a cube
|
||||||
|
that writes next to its own `deploy.py` corrupts that store for every project on
|
||||||
|
the machine. Cubes here write to `/tmp` or to the remote host, never to their
|
||||||
|
own directory.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'admin:cockpit',
|
id: 'admin:cockpit',
|
||||||
name: 'Install cockpit and utils',
|
name: 'Install cockpit and utils',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
import { cubes, uniqid } from '@bitstack/nopy';
|
import { Manifest, uniqid } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manifest for the admin:hostname cube.
|
* Manifest for the admin:hostname cube.
|
||||||
* This cube allows for setting and persistently changing the system's hostname.
|
* This cube allows for setting and persistently changing the system's hostname.
|
||||||
*/
|
*/
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'admin:hostname',
|
id: 'admin:hostname',
|
||||||
name: 'Permanently change the hostname',
|
name: 'Permanently change the hostname',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -12,9 +12,9 @@ Configures system keyboard layout permanently by updating `/etc/default/keyboard
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
name: 'My Host Setup',
|
name: 'My Host Setup',
|
||||||
dependencies: () => [
|
dependencies: () => [
|
||||||
['admin:locale', { LAYOUT: 'de' }]
|
['admin:locale', { LAYOUT: 'de' }]
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'admin:locale',
|
id: 'admin:locale',
|
||||||
name: 'Configure system locale and keyboard layout',
|
name: 'Configure system locale and keyboard layout',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'apt:essentials',
|
id: 'apt:essentials',
|
||||||
name: 'Install essential packages',
|
name: 'Install essential packages',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'apt:install',
|
id: 'apt:install',
|
||||||
name: 'Install packages with apt',
|
name: 'Install packages with apt',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'armor:fail2ban',
|
id: 'armor:fail2ban',
|
||||||
name: 'Install and enable fail2ban',
|
name: 'Install and enable fail2ban',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'armor:ssh',
|
id: 'armor:ssh',
|
||||||
name: 'Secure SSH server by disabling password authentication',
|
name: 'Secure SSH server by disabling password authentication',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'armor:ufw',
|
id: 'armor:ufw',
|
||||||
name: 'Activate ufw (uncomplicated firewall)',
|
name: 'Activate ufw (uncomplicated firewall)',
|
||||||
dependencies: () => ['apt:essentials'],
|
dependencies: () => ['apt:essentials'],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'caddy',
|
id: 'caddy',
|
||||||
name: 'Install Caddy webserver',
|
name: 'Install Caddy webserver',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'caddy:spa',
|
id: 'caddy:spa',
|
||||||
name: 'Install single page application',
|
name: 'Install single page application',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'git:clone',
|
id: 'git:clone',
|
||||||
name: 'Clone a repository',
|
name: 'Clone a repository',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
+7
-1
@@ -13,7 +13,7 @@ Installs and authenticates the Tailscale client on a Linux host.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `AUTH_KEY` | `""` | Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). |
|
| `AUTH_KEY` | `""` | **Secret.** Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). |
|
||||||
| `LOGIN_SERVER` | `https://controlplane.tailscale.com` | The coordination server URL. Set this to your Headscale instance URL if applicable. |
|
| `LOGIN_SERVER` | `https://controlplane.tailscale.com` | The coordination server URL. Set this to your Headscale instance URL if applicable. |
|
||||||
| `EXTRA_ARGS` | `""` | Additional flags to pass to `tailscale up` (e.g., `--advertise-exit-node`). |
|
| `EXTRA_ARGS` | `""` | Additional flags to pass to `tailscale up` (e.g., `--advertise-exit-node`). |
|
||||||
| `FORCE_REAUTH` | `false` | If true, forces the client to re-authenticate. |
|
| `FORCE_REAUTH` | `false` | If true, forces the client to re-authenticate. |
|
||||||
@@ -25,3 +25,9 @@ nopy install tailscale
|
|||||||
```
|
```
|
||||||
|
|
||||||
When prompted, provide your `AUTH_KEY`. If you are using Headscale, also provide the `LOGIN_SERVER` URL.
|
When prompted, provide your `AUTH_KEY`. If you are using Headscale, also provide the `LOGIN_SERVER` URL.
|
||||||
|
|
||||||
|
`AUTH_KEY` is declared in the manifest's `secrets`, so nopy keeps it out of session
|
||||||
|
and history files and masks it in any command it prints. It is asked for again on
|
||||||
|
replay, and a `--use-defaults` replay refuses rather than joining the tailnet with
|
||||||
|
an empty key. Prefer an ephemeral key regardless — the value is still on pyinfra's
|
||||||
|
command line while the deployment runs.
|
||||||
+3
-2
@@ -1,10 +1,11 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'net:tailscale',
|
id: 'net:tailscale',
|
||||||
name: 'Install and authenticate Tailscale',
|
name: 'Install and authenticate Tailscale',
|
||||||
dependencies: () => ['apt:essentials'],
|
dependencies: () => ['apt:essentials'],
|
||||||
|
secrets: ['AUTH_KEY'],
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
AUTH_KEY: z.string().describe('Tailscale Auth Key for headless authentication').default(''),
|
AUTH_KEY: z.string().describe('Tailscale Auth Key for headless authentication').default(''),
|
||||||
LOGIN_SERVER: z
|
LOGIN_SERVER: z
|
||||||
+12
-3
@@ -19,10 +19,19 @@ Configures a Linux device as a WiFi Access Point using NetworkManager's `nmcli`
|
|||||||
|
|
||||||
## Configuration Parameters
|
## Configuration Parameters
|
||||||
|
|
||||||
### Required
|
> **This section is out of date** — it lists parameters the manifest does not
|
||||||
|
> declare (`NETWORK_DEVICE`, `CHANNEL`, `IP_ADDRESS`) and omits `AP_IP`. Read
|
||||||
|
> `manifest.mjs` for the real list. Tracked as §5 in the repository's
|
||||||
|
> `DOCS-AUDIT.md`.
|
||||||
|
|
||||||
- **SSID**: WiFi network name (1-32 characters)
|
### Prompted first
|
||||||
- **PASSWORD**: WPA2 password (8-63 characters)
|
|
||||||
|
- **SSID**: WiFi network name (1-32 characters). Defaults to `PiPoint`.
|
||||||
|
- **PASSWORD**: WPA2 password (8-63 characters). Defaults to `1223334444` — a
|
||||||
|
placeholder that should not survive contact with a real network.
|
||||||
|
Declared in the manifest's `secrets`, so nopy keeps it out of session and
|
||||||
|
history files and masks it in printed commands, and re-prompts on replay. The
|
||||||
|
value is still on pyinfra's command line, so it is visible in `ps` during the run.
|
||||||
|
|
||||||
### Optional (with defaults)
|
### Optional (with defaults)
|
||||||
|
|
||||||
+3
-2
@@ -1,10 +1,11 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'net:wifi:access-point',
|
id: 'net:wifi:access-point',
|
||||||
name: 'Configure WiFi Access Point (NetworkManager)',
|
name: 'Configure WiFi Access Point (NetworkManager)',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
|
secrets: ['PASSWORD'],
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
SSID: z.string().min(1).max(32).default('PiPoint').describe('WiFi network name (SSID)'),
|
SSID: z.string().min(1).max(32).default('PiPoint').describe('WiFi network name (SSID)'),
|
||||||
PASSWORD: z
|
PASSWORD: z
|
||||||
+5
@@ -42,6 +42,11 @@ nopy install network:wifi:connection --env SSID="OfficeWiFi" --env PASSWORD="pas
|
|||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
|
- `PASSWORD` is declared in the manifest's `secrets`: nopy keeps it out of session
|
||||||
|
and history files and masks it in every command it prints. It is prompted for
|
||||||
|
again on replay.
|
||||||
|
- That covers what nopy writes, not everything. The value is still on pyinfra's
|
||||||
|
command line, so it is visible in `ps` while the deployment runs.
|
||||||
- WiFi passwords will be stored in `/etc/NetworkManager/system-connections/` on the target host.
|
- WiFi passwords will be stored in `/etc/NetworkManager/system-connections/` on the target host.
|
||||||
- Passing passwords via `--env` may leave them in your local shell history.
|
- Passing passwords via `--env` may leave them in your local shell history.
|
||||||
|
|
||||||
+3
-2
@@ -1,4 +1,4 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||||
@@ -7,9 +7,10 @@ import { z } from 'zod';
|
|||||||
* Manifest for the network:wifi:connection cube.
|
* Manifest for the network:wifi:connection cube.
|
||||||
* Configures a WiFi client connection using NetworkManager (nmcli).
|
* Configures a WiFi client connection using NetworkManager (nmcli).
|
||||||
*/
|
*/
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'net:wifi:connection',
|
id: 'net:wifi:connection',
|
||||||
name: 'network:wifi:connection - Connect to a WiFi network',
|
name: 'network:wifi:connection - Connect to a WiFi network',
|
||||||
|
secrets: ['PASSWORD'],
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'),
|
SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'),
|
||||||
PASSWORD: z.string().min(8).describe('The password for the WiFi network'),
|
PASSWORD: z.string().min(8).describe('The password for the WiFi network'),
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { cubes } from '@bitstack/nopy';
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default cubes.Manifest({
|
export default Manifest({
|
||||||
id: 'runtime:docker',
|
id: 'runtime:docker',
|
||||||
name: 'Install docker and tools',
|
name: 'Install docker and tools',
|
||||||
dependencies: () => [],
|
dependencies: () => [],
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user