Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s
Publish snapshot / snapshot (push) Failing after 1m58s
Publishing infrastructure - Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs) - Tag-driven releases as <package-dir>-v<version>; the manifest stays the source of truth and release.yml refuses to run if tag and manifest disagree - Every publish is idempotent: each step checks the registry first, so a run that fails on the second registry can simply be re-run - Hard coverage gate (85% branches) shared by CI, the pre-push hook and local runs, since the thresholds live in vitest.config.ts rather than a CI flag - README.PUBLISH.md documents the whole mechanism Toolchain - TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs - Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0 - Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9, with enquirer's AutoComplete; the CubeSelection contract is unchanged - Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema Repo hygiene - Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json - Drop package-lock.json in favour of pnpm-lock.yaml Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# Verification gate for everything that is not a `main` push.
|
||||
#
|
||||
# `main` is covered by publish-snapshot.yml, which runs the identical gate
|
||||
# before it publishes — running both here would just duplicate the work.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
# Version comes from `packageManager` in the root package.json.
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Locate the pnpm store
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore the pnpm store
|
||||
# A runner without a cache server should be slow, not broken.
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint:ci
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Test with coverage
|
||||
# Fails the job below 85% branch coverage — see the `thresholds` block
|
||||
# in each package's vitest.config.ts.
|
||||
run: pnpm run test:coverage
|
||||
|
||||
- name: Summarise coverage
|
||||
# Reporting only — the gate is the step above.
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: pnpm run coverage:summary
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Check the published file lists
|
||||
# Scripts are off because the build already ran; `prepack` would only
|
||||
# repeat it. Catches a `files`/`bin` entry that no longer exists.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for pkg in packages/*/; do
|
||||
echo "::group::npm pack $pkg"
|
||||
(cd "$pkg" && npm pack --dry-run --ignore-scripts)
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage
|
||||
path: packages/*/coverage
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,124 @@
|
||||
# Every commit that lands on `main` publishes a prerelease of both packages to
|
||||
# the Gitea npm registry under the `main` dist-tag:
|
||||
#
|
||||
# pnpm add @bitstack/nopy@main
|
||||
#
|
||||
# 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 —
|
||||
# releases to npmjs are cut by hand via a tag (see release.yml).
|
||||
|
||||
name: Publish snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: snapshot-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# e.g. https://gitea.example.com/api/packages/BitSquare/npm/
|
||||
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/
|
||||
# GITEA_TOKEN is injected automatically; override with a PAT that carries
|
||||
# `write:package` if the automatic token is not scoped for the registry.
|
||||
REGISTRY_TOKEN: ${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }}
|
||||
NPMRC: ${{ github.workspace }}/.npmrc-gitea
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
# Version comes from `packageManager` in the root package.json.
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Locate the pnpm store
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore the pnpm store
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint:ci
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Test with coverage
|
||||
run: pnpm run test:coverage
|
||||
|
||||
- name: Summarise coverage
|
||||
# Reporting only — the gate is the step above.
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: pnpm run coverage:summary
|
||||
|
||||
- name: Build
|
||||
# Explicit, so the publish step can skip lifecycle scripts entirely.
|
||||
run: pnpm run build
|
||||
|
||||
- name: Authenticate against the Gitea registry
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${REGISTRY_TOKEN}" ]; then
|
||||
echo "::error::No registry token. Add a GITEA_NPM_TOKEN secret with write:package scope."
|
||||
exit 1
|
||||
fi
|
||||
install -m 600 /dev/null "$NPMRC"
|
||||
{
|
||||
printf '@bitstack:registry=%s\n' "$REGISTRY"
|
||||
printf '//%s:_authToken=%s\n' "${REGISTRY#*://}" "$REGISTRY_TOKEN"
|
||||
} >> "$NPMRC"
|
||||
|
||||
- name: Publish snapshots
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export npm_config_userconfig="$NPMRC"
|
||||
: "${GITHUB_STEP_SUMMARY:=/dev/null}"
|
||||
short_sha=$(git rev-parse --short=7 HEAD)
|
||||
|
||||
for dir in packages/*/; do
|
||||
name=$(node -p "require('./${dir}package.json').name")
|
||||
base=$(node -p "require('./${dir}package.json').version")
|
||||
# `g` prefix keeps the identifier a valid semver one even when the
|
||||
# abbreviated sha happens to be all digits.
|
||||
version="${base}-main.${{ github.run_number }}.g${short_sha}"
|
||||
|
||||
echo "::group::${name}@${version}"
|
||||
if npm view "${name}@${version}" version --registry "$REGISTRY" >/dev/null 2>&1; then
|
||||
echo "Already published — skipping (this is a re-run of the same workflow)."
|
||||
else
|
||||
(
|
||||
cd "$dir"
|
||||
npm pkg set "version=${version}"
|
||||
npm publish --ignore-scripts --tag main --registry "$REGISTRY"
|
||||
)
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "- \`pnpm add ${name}@${version}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
|
||||
- name: Remove the registry credentials
|
||||
if: always()
|
||||
run: rm -f "$NPMRC"
|
||||
@@ -0,0 +1,243 @@
|
||||
# Tag-driven release of a single package.
|
||||
#
|
||||
# git tag nopy-v1.2.0 && git push origin nopy-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
|
||||
# source of truth for the version, and the two must agree or the run fails.
|
||||
# A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead
|
||||
# of `latest`.
|
||||
#
|
||||
# Required secrets:
|
||||
# NPM_TOKEN npmjs automation token with publish rights on @bitstack
|
||||
# GITEA_NPM_TOKEN optional; PAT with write:package if GITEA_TOKEN is not enough
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*-v*'
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
GITEA_REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }}
|
||||
NPMJS_REGISTRY: https://registry.npmjs.org/
|
||||
NPMJS_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPMRC: ${{ github.workspace }}/.npmrc-release
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve the release from the tag
|
||||
id: target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
pkg="${tag%-v*}"
|
||||
version="${tag##*-v}"
|
||||
dir="packages/${pkg}"
|
||||
|
||||
if [ ! -f "${dir}/package.json" ]; then
|
||||
echo "::error::Tag '${tag}' names package '${pkg}', but ${dir}/package.json does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declared=$(node -p "require('./${dir}/package.json').version")
|
||||
if [ "$declared" != "$version" ]; then
|
||||
echo "::error::Tag '${tag}' asks for ${version}, but ${dir}/package.json declares ${declared}. Bump the manifest and re-tag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
name=$(node -p "require('./${dir}/package.json').name")
|
||||
case "$version" in
|
||||
*-*) dist_tag=next ;;
|
||||
*) dist_tag=latest ;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "dir=${dir}"
|
||||
echo "name=${name}"
|
||||
echo "version=${version}"
|
||||
echo "dist_tag=${dist_tag}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Releasing ${name}@${version} from ${dir} as '${dist_tag}'."
|
||||
|
||||
- name: Check the required secrets are present
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=0
|
||||
[ -n "${NPMJS_TOKEN}" ] || { echo "::error::NPM_TOKEN secret is not set."; missing=1; }
|
||||
[ -n "${GITEA_REGISTRY_TOKEN}" ] || { echo "::error::No Gitea registry token available."; missing=1; }
|
||||
exit "$missing"
|
||||
|
||||
- name: Set up pnpm
|
||||
# Version comes from `packageManager` in the root package.json.
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Locate the pnpm store
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore the pnpm store
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint:ci
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Test with coverage
|
||||
run: pnpm run test:coverage
|
||||
|
||||
- name: Build
|
||||
# Explicit, so the publish steps can skip lifecycle scripts entirely.
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to the Gitea registry
|
||||
env:
|
||||
NAME: ${{ steps.target.outputs.name }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
DIST_TAG: ${{ steps.target.outputs.dist_tag }}
|
||||
DIR: ${{ steps.target.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -m 600 /dev/null "$NPMRC"
|
||||
{
|
||||
printf '@bitstack:registry=%s\n' "$GITEA_REGISTRY"
|
||||
printf '//%s:_authToken=%s\n' "${GITEA_REGISTRY#*://}" "$GITEA_REGISTRY_TOKEN"
|
||||
} >> "$NPMRC"
|
||||
export npm_config_userconfig="$NPMRC"
|
||||
|
||||
if npm view "${NAME}@${VERSION}" version --registry "$GITEA_REGISTRY" >/dev/null 2>&1; then
|
||||
echo "${NAME}@${VERSION} is already on Gitea — skipping."
|
||||
else
|
||||
(cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --registry "$GITEA_REGISTRY")
|
||||
fi
|
||||
|
||||
- name: Publish to npmjs
|
||||
env:
|
||||
NAME: ${{ steps.target.outputs.name }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
DIST_TAG: ${{ steps.target.outputs.dist_tag }}
|
||||
DIR: ${{ steps.target.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -m 600 /dev/null "$NPMRC"
|
||||
{
|
||||
printf '@bitstack:registry=%s\n' "$NPMJS_REGISTRY"
|
||||
printf '//%s:_authToken=%s\n' "${NPMJS_REGISTRY#*://}" "$NPMJS_TOKEN"
|
||||
} >> "$NPMRC"
|
||||
export npm_config_userconfig="$NPMRC"
|
||||
|
||||
if npm view "${NAME}@${VERSION}" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||
echo "${NAME}@${VERSION} is already on npmjs — skipping."
|
||||
else
|
||||
# No --provenance: that needs GitHub Actions OIDC, which Gitea has no
|
||||
# equivalent for.
|
||||
(cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --access public --registry "$NPMJS_REGISTRY")
|
||||
fi
|
||||
|
||||
- name: Remove the registry credentials
|
||||
if: always()
|
||||
run: rm -f "$NPMRC"
|
||||
|
||||
- name: Create the Gitea release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TAG: ${{ steps.target.outputs.tag }}
|
||||
NAME: ${{ steps.target.outputs.name }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
DIST_TAG: ${{ steps.target.outputs.dist_tag }}
|
||||
DIR: ${{ steps.target.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
|
||||
status=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" "${api}/tags/${TAG}")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "A release for ${TAG} already exists — leaving it alone."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The section of the hand-written changelog that names this version.
|
||||
notes=""
|
||||
if [ -f "${DIR}/CHANGELOG.md" ]; then
|
||||
notes=$(awk -v v="$VERSION" '
|
||||
/^## / { if (found) exit; if (index($0, v)) { found = 1; next } }
|
||||
found { print }
|
||||
' "${DIR}/CHANGELOG.md")
|
||||
fi
|
||||
export NOTES="$notes"
|
||||
|
||||
node -e '
|
||||
const { NAME, VERSION, TAG, DIST_TAG, NOTES } = process.env;
|
||||
const install =
|
||||
DIST_TAG === "latest"
|
||||
? `npm install -g ${NAME}`
|
||||
: `npm install -g ${NAME}@${VERSION}`;
|
||||
const body = [
|
||||
NOTES.trim(),
|
||||
"",
|
||||
"```sh",
|
||||
install,
|
||||
"```",
|
||||
].join("\n").trim();
|
||||
console.log(JSON.stringify({
|
||||
tag_name: TAG,
|
||||
name: `${NAME} v${VERSION}`,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: DIST_TAG !== "latest",
|
||||
}));
|
||||
' > release.json
|
||||
|
||||
curl -sS -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @release.json \
|
||||
"$api"
|
||||
rm -f release.json
|
||||
|
||||
- name: Summarise
|
||||
# Reporting only; never the reason a green release goes red.
|
||||
continue-on-error: true
|
||||
env:
|
||||
NAME: ${{ steps.target.outputs.name }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
DIST_TAG: ${{ steps.target.outputs.dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${GITHUB_STEP_SUMMARY:=/dev/null}"
|
||||
{
|
||||
echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)"
|
||||
echo ""
|
||||
echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`"
|
||||
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --registry ${GITEA_REGISTRY}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -7,9 +7,18 @@ cache
|
||||
vault/tmp
|
||||
age.key
|
||||
|
||||
dist
|
||||
coverage
|
||||
tsconfig.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
.nopy.history.json
|
||||
|
||||
*.img
|
||||
*.img.gz
|
||||
|
||||
# Registry credentials and payloads written into the workspace by the publish
|
||||
# workflows — never wanted in a commit.
|
||||
.npmrc
|
||||
.npmrc-*
|
||||
release.json
|
||||
|
||||
@@ -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,402 @@
|
||||
# Publishing
|
||||
|
||||
Everything about how the packages in this repository are verified, versioned and
|
||||
shipped. If you only want to cut a release, jump to
|
||||
[Cutting a release](#cutting-a-release).
|
||||
|
||||
- [What ships](#what-ships)
|
||||
- [The three workflows](#the-three-workflows)
|
||||
- [The verification gate](#the-verification-gate)
|
||||
- [Versions and dist-tags](#versions-and-dist-tags)
|
||||
- [Snapshots](#snapshots)
|
||||
- [Cutting a release](#cutting-a-release)
|
||||
- [Changelogs and release notes](#changelogs-and-release-notes)
|
||||
- [Secrets](#secrets)
|
||||
- [Registry authentication in the workflows](#registry-authentication-in-the-workflows)
|
||||
- [Installing the packages](#installing-the-packages)
|
||||
- [Design decisions](#design-decisions)
|
||||
- [Checking things locally](#checking-things-locally)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Recovering from a bad publish](#recovering-from-a-bad-publish)
|
||||
|
||||
## What ships
|
||||
|
||||
| Directory | Package | Binary |
|
||||
| ----------------- | ------------------ | -------- |
|
||||
| `packages/nopy` | `@bitstack/nopy` | `nopy` |
|
||||
| `packages/keyman` | `@bitstack/keyman` | `keyman` |
|
||||
|
||||
Both are ESM, both declare `engines.node >= 22`, and both expose a single
|
||||
executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the
|
||||
`PATH`. `cubes/` is not a package and is never published.
|
||||
|
||||
The tarball contents are pinned by `files: ["dist", "README.md", "LICENSE"]` —
|
||||
sources and tests are not shipped. `publishConfig.access: "public"` is what makes
|
||||
a scoped package publishable to npmjs without an extra flag; the workflows pass
|
||||
`--access public` anyway.
|
||||
|
||||
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
|
||||
tagged a commit whose manifest already carries the version you want.
|
||||
|
||||
## The three workflows
|
||||
|
||||
All three live in [`.gitea/workflows`](.gitea/workflows) and run on the
|
||||
`ubuntu-latest` runner label.
|
||||
|
||||
| Workflow | Trigger | Publishes |
|
||||
| ---------------------- | -------------------------------- | ------------------------------------------ |
|
||||
| `ci.yml` | pull requests, non-`main` pushes | nothing |
|
||||
| `publish-snapshot.yml` | pushes to `main` | **both** packages → Gitea, tag `main` |
|
||||
| `release.yml` | tags matching `*-v*` | **one** package → Gitea **and** npmjs |
|
||||
|
||||
`ci.yml` explicitly excludes `main` and all tags (`branches-ignore` +
|
||||
`tags-ignore`). That is not an oversight: both publish workflows carry the full
|
||||
gate themselves, so excluding them here means no commit is ever verified twice,
|
||||
while nothing is ever published from an unverified tree.
|
||||
|
||||
Concurrency: `ci.yml` cancels superseded runs for the same ref
|
||||
(`cancel-in-progress: true`); the two publish workflows never cancel, because a
|
||||
half-finished publish is worse than a slow queue.
|
||||
|
||||
### `ci.yml`
|
||||
|
||||
```
|
||||
checkout → pnpm → node → pnpm store cache → install
|
||||
→ lint:ci → typecheck → test:coverage → coverage summary
|
||||
→ build → npm pack --dry-run (per package) → upload coverage
|
||||
```
|
||||
|
||||
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
|
||||
something the build no longer produces — a failure that would otherwise only
|
||||
surface after the version is already on a registry and immutable.
|
||||
|
||||
Coverage HTML/JSON reports are uploaded as a `coverage` artifact with a 7-day
|
||||
retention. Both the artifact upload and the store cache are
|
||||
`continue-on-error: true`, so a runner without a cache or artifact server gets
|
||||
slower CI rather than broken CI.
|
||||
|
||||
### `publish-snapshot.yml`
|
||||
|
||||
```
|
||||
checkout → pnpm → node → cache → install
|
||||
→ lint:ci → typecheck → test:coverage → coverage summary → build
|
||||
→ write .npmrc → publish both packages → delete .npmrc
|
||||
```
|
||||
|
||||
One job, no `needs:` barrier, so install and build happen exactly once and
|
||||
nothing has to be passed between jobs as an artifact.
|
||||
|
||||
### `release.yml`
|
||||
|
||||
```
|
||||
checkout → resolve tag → check secrets
|
||||
→ pnpm → node → cache → install
|
||||
→ lint:ci → typecheck → test:coverage → build
|
||||
→ publish to Gitea → publish to npmjs → delete .npmrc
|
||||
→ create the Gitea release → step summary
|
||||
```
|
||||
|
||||
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
|
||||
the whole gate.
|
||||
|
||||
## The verification gate
|
||||
|
||||
The same three commands guard every path into a registry:
|
||||
|
||||
| Step | Command |
|
||||
| --------------- | ----------------------- |
|
||||
| Lint | `pnpm run lint:ci` |
|
||||
| Typecheck | `pnpm run typecheck` |
|
||||
| Test + coverage | `pnpm run test:coverage`|
|
||||
|
||||
Coverage is a hard failure below **85 % branches** (and 85 % functions, 80 %
|
||||
lines, 80 % statements). The thresholds live in `coverage.thresholds` in each
|
||||
package's `vitest.config.ts`, not in a CI-only flag, which means
|
||||
`pnpm run test:coverage` fails identically on a laptop, in the `pre-push` hook,
|
||||
and on the runner. Barrel files and CLI argv wiring are excluded from
|
||||
measurement; everything with behaviour in it is not.
|
||||
|
||||
The `pre-push` hook installed by `simple-git-hooks` runs exactly this gate, so a
|
||||
push that survives locally will not surprise you on the runner. Bypass with
|
||||
`SKIP_SIMPLE_GIT_HOOKS=1` when you must — CI will still catch it.
|
||||
|
||||
`pnpm run coverage:summary` renders the per-package `json-summary` reports as a
|
||||
Markdown table. In CI it appends to `$GITHUB_STEP_SUMMARY` so the numbers show up
|
||||
on the run page without opening the log. It is reporting only — `if: always()`
|
||||
and `continue-on-error: true` — and can never be the reason a run goes red.
|
||||
|
||||
## Versions and dist-tags
|
||||
|
||||
| Source | Version | Registry | dist-tag |
|
||||
| ----------------------------------------- | ----------------------------- | ------------ | -------- |
|
||||
| push to `main` | `1.0.0-main.42.g736c012` | Gitea | `main` |
|
||||
| tag `nopy-v1.2.0` | `1.2.0` | Gitea, npmjs | `latest` |
|
||||
| tag `nopy-v1.2.0-rc.1` | `1.2.0-rc.1` | Gitea, npmjs | `next` |
|
||||
|
||||
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
|
||||
the Gitea release; anything else goes out as `latest`. There is no way to publish
|
||||
a prerelease over `latest` by accident.
|
||||
|
||||
## Snapshots
|
||||
|
||||
Every commit that lands on `main` publishes both packages to the Gitea registry,
|
||||
versioned as:
|
||||
|
||||
```
|
||||
<manifest version>-main.<run number>.g<short sha>
|
||||
```
|
||||
|
||||
for example `1.0.0-main.42.g736c012`. The `g` prefix keeps the identifier valid
|
||||
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.
|
||||
|
||||
```sh
|
||||
pnpm add @bitstack/nopy@main
|
||||
```
|
||||
|
||||
Snapshots never reach npmjs and never move `latest`. The version is written into
|
||||
the manifest on the runner with `npm pkg set` immediately before publishing; that
|
||||
edit is discarded with the workspace and is never committed.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
1. Bump `version` in `packages/<pkg>/package.json`.
|
||||
2. Add a changelog entry (see below).
|
||||
3. Commit, merge to `main`, and let the snapshot workflow go green.
|
||||
4. Tag that commit and push the tag:
|
||||
|
||||
```sh
|
||||
git tag nopy-v1.2.0
|
||||
git push origin nopy-v1.2.0
|
||||
```
|
||||
|
||||
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 tag decides **which** package ships; `package.json` decides the **version**.
|
||||
The workflow re-reads the manifest and refuses to continue if the two disagree:
|
||||
|
||||
```
|
||||
::error::Tag 'nopy-v1.2.0' asks for 1.2.0, but packages/nopy/package.json
|
||||
declares 1.1.0. Bump the manifest and re-tag.
|
||||
```
|
||||
|
||||
An unknown package name fails the same way. Both checks run before install, so a
|
||||
typo costs you seconds.
|
||||
|
||||
Tagging one package does not touch the other. Two packages at the same version is
|
||||
a coincidence, not a requirement.
|
||||
|
||||
What a successful run leaves behind:
|
||||
|
||||
- `@bitstack/<pkg>@<version>` on the Gitea registry
|
||||
- the same tarball on npmjs, public, under `latest` or `next`
|
||||
- a Gitea release on the tag, with notes and an install snippet
|
||||
- a step summary with both install commands
|
||||
|
||||
## Changelogs and release notes
|
||||
|
||||
Neither package has a `CHANGELOG.md` yet. Without one, the Gitea release body is
|
||||
just the install snippet — nothing fails.
|
||||
|
||||
When you add one, `release.yml` extracts the section for the version being
|
||||
released. The parser is deliberately dumb: it looks for the first `## ` heading
|
||||
whose text contains the version string, and takes every line until the next `## `
|
||||
heading. Any of these work:
|
||||
|
||||
```markdown
|
||||
## 1.2.0
|
||||
|
||||
## [1.2.0] - 2026-07-27
|
||||
|
||||
## v1.2.0 — Faster inventory parsing
|
||||
```
|
||||
|
||||
Version strings that are prefixes of each other are the one thing to watch: with
|
||||
both `## 1.2.0` and `## 1.2.0-rc.1` in the file, releasing `1.2.0` matches
|
||||
whichever heading comes first. Keep the newest at the top, as usual, and this
|
||||
resolves itself.
|
||||
|
||||
## Secrets
|
||||
|
||||
Configure under **Settings → Actions → Secrets**, on the repository or on the
|
||||
organisation to share across repos.
|
||||
|
||||
| Secret | Required | Purpose |
|
||||
| ----------------- | -------- | ---------------------------------------------------------------- |
|
||||
| `NPM_TOKEN` | yes | npmjs token with publish rights on the `@bitstack` scope |
|
||||
| `GITEA_NPM_TOKEN` | maybe | Gitea PAT with `write:package`, if the built-in token is not enough |
|
||||
|
||||
`GITEA_TOKEN` is injected into every run by Gitea itself, and the workflows fall
|
||||
back to it via `${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }}`. Add
|
||||
`GITEA_NPM_TOKEN` only if the automatic token turns out not to carry
|
||||
package-write scope on your instance — the failure mode is a `401` or `403` from
|
||||
the registry at the publish step, with the gate already green.
|
||||
|
||||
Create the npmjs token as an **automation** token, or as a granular token scoped
|
||||
to `@bitstack/*` with read-and-write permission. Classic *publish* tokens tied to
|
||||
2FA prompt interactively and cannot work on a runner.
|
||||
|
||||
`release.yml` refuses to start if either token is missing, and says which one.
|
||||
|
||||
> npm `--provenance` is deliberately not used. It requires GitHub Actions OIDC,
|
||||
> which Gitea has no equivalent for; passing the flag would only fail the
|
||||
> publish. Releases are therefore unsigned in the provenance sense — the audit
|
||||
> trail is the tag, the run log, and the Gitea release.
|
||||
|
||||
## Registry authentication in the workflows
|
||||
|
||||
`release.yml` has to talk to two different registries about the same `@bitstack`
|
||||
scope inside one job. It does that without ever mutating `~/.npmrc`:
|
||||
|
||||
- each publish step writes its own credentials file, created with
|
||||
`install -m 600 /dev/null` (which both sets the mode and truncates whatever was
|
||||
there before);
|
||||
- `npm_config_userconfig` points npm at that file for the duration of the step;
|
||||
- the npmjs step therefore starts from a file that no longer contains the Gitea
|
||||
token;
|
||||
- a final `if: always()` step deletes it, so it does not survive into a later
|
||||
step or a cached workspace.
|
||||
|
||||
`.npmrc`, `.npmrc-*` and `release.json` are in `.gitignore`, so a credentials
|
||||
file written into the workspace can never be committed by accident.
|
||||
|
||||
## Installing the packages
|
||||
|
||||
From npmjs — public, no configuration:
|
||||
|
||||
```sh
|
||||
npm install -g @bitstack/nopy @bitstack/keyman
|
||||
```
|
||||
|
||||
From the Gitea registry, which holds every snapshot plus a mirror of every
|
||||
release. Per-project, in the repo's `.npmrc`:
|
||||
|
||||
```ini
|
||||
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
```
|
||||
|
||||
Globally with credentials, in `~/.npmrc`:
|
||||
|
||||
```ini
|
||||
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
//gitea.bitsquare.dev/api/packages/BitSquare/npm/:_authToken=<your gitea token>
|
||||
```
|
||||
|
||||
Reads only need a token if the repository is private. The registry URL is derived
|
||||
in the workflows as `{server_url}/api/packages/{owner}/npm/`, so it follows the
|
||||
instance and organisation automatically.
|
||||
|
||||
To track snapshots in another project:
|
||||
|
||||
```sh
|
||||
pnpm add @bitstack/nopy@main
|
||||
```
|
||||
|
||||
## Design decisions
|
||||
|
||||
**Every publish is idempotent.** Each step asks the registry whether that exact
|
||||
version already exists (`npm view <name>@<version>`) and skips if it does. A
|
||||
release that publishes to Gitea and then fails on npmjs can simply be re-run: the
|
||||
Gitea publish is skipped, the npmjs publish proceeds. This matters because Gitea
|
||||
refuses to overwrite an existing version — without the check, a re-run would fail
|
||||
on the first registry and never reach the second.
|
||||
|
||||
**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
|
||||
step, and repeating it inside `npm publish` would only cost time and add a way
|
||||
for a lifecycle script to change what ships after the gate looked at it.
|
||||
|
||||
**One job per workflow.** No artifact hand-off, no second install, no risk of
|
||||
publishing a tree that a different job built.
|
||||
|
||||
**Caching is best-effort.** `actions/cache@v4` is wrapped in
|
||||
`continue-on-error: true`, and `setup-node`'s built-in `cache:` is not used, so
|
||||
a Gitea runner without a cache backend still works.
|
||||
|
||||
**`actions/upload-artifact@v3`, not v4** — v4 depends on a backend API that many
|
||||
Gitea runner setups do not implement.
|
||||
|
||||
**pnpm and Node versions come from the repo.** `pnpm/action-setup@v4` reads
|
||||
`packageManager` from the root manifest; `setup-node` reads `.nvmrc`. There is no
|
||||
version pinned in the workflow files to drift out of sync.
|
||||
|
||||
## Checking things locally
|
||||
|
||||
Reproduce the CI gate exactly:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage
|
||||
```
|
||||
|
||||
See what would actually be in the tarball:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
cd packages/nopy && npm pack --dry-run --ignore-scripts
|
||||
```
|
||||
|
||||
Try the binary as an end user would get it, without publishing:
|
||||
|
||||
```sh
|
||||
cd packages/nopy && pnpm run link:local # build + npm link
|
||||
nopy --help
|
||||
npm unlink -g @bitstack/nopy
|
||||
```
|
||||
|
||||
Check that a version is not already taken before you tag:
|
||||
|
||||
```sh
|
||||
npm view @bitstack/nopy@1.2.0 version # npmjs
|
||||
npm view @bitstack/nopy@1.2.0 version \
|
||||
--registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `Tag ... asks for X, but package.json declares Y` | The manifest was not bumped, or the tag is on the wrong commit. Fix the manifest, re-tag. |
|
||||
| `Tag ... names package 'foo', but packages/foo/package.json does not exist` | Tag prefix must be the directory name under `packages/`. |
|
||||
| `NPM_TOKEN secret is not set` | Add the secret; the run stops before installing anything. |
|
||||
| `401`/`403` from the Gitea registry | The automatic `GITEA_TOKEN` lacks `write:package`. Add a `GITEA_NPM_TOKEN` PAT — no edit needed. |
|
||||
| `E409 Conflict` / version already exists | Only reachable if a version was published outside the workflow; the `npm view` guard covers re-runs. |
|
||||
| Coverage step fails, thresholds look met | Thresholds are per package. Read which package failed — the summary table shows both. |
|
||||
| `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. |
|
||||
| The release workflow did not trigger | The tag must match `*-v*` and must be pushed (`git push origin <tag>`), not just created. |
|
||||
|
||||
## Recovering from a bad publish
|
||||
|
||||
**On npmjs**, a version is permanent. Do not try to re-use it — publish a patch.
|
||||
Meanwhile:
|
||||
|
||||
```sh
|
||||
npm dist-tag add @bitstack/nopy@1.1.9 latest # point users back
|
||||
npm deprecate @bitstack/nopy@1.2.0 "Broken build, use 1.2.1"
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
**On Gitea**, delete the version under **Packages → @bitstack/… → Settings**
|
||||
before that exact version can be published again.
|
||||
|
||||
**A bad tag** can be moved, but only before the release workflow has published
|
||||
anything:
|
||||
|
||||
```sh
|
||||
git push --delete origin nopy-v1.2.0
|
||||
git tag -d nopy-v1.2.0
|
||||
```
|
||||
|
||||
Once a tarball is on npmjs, the tag is a historical record — leave it and roll
|
||||
forward.
|
||||
|
||||
**A bad snapshot** needs no action at all: the next push to `main` produces a new
|
||||
run number and therefore a new version, and `@main` moves to it.
|
||||
@@ -1,2 +1,62 @@
|
||||
# ansiblings
|
||||
|
||||
Infrastructure tooling monorepo: two published CLIs plus the pyinfra "cubes"
|
||||
they deploy.
|
||||
|
||||
| Path | Package | Binary | What it is |
|
||||
| ----------------- | ------------------ | -------- | --------------------------------------------------- |
|
||||
| `packages/nopy` | `@bitstack/nopy` | `nopy` | interactive pyinfra script management and execution |
|
||||
| `packages/keyman` | `@bitstack/keyman` | `keyman` | SSH key management with `age` encryption |
|
||||
| `cubes/` | — | — | the deployment units `nopy` runs |
|
||||
|
||||
```sh
|
||||
npm install -g @bitstack/nopy @bitstack/keyman
|
||||
```
|
||||
|
||||
See each package's README for usage, and
|
||||
[README.PUBLISH.md](README.PUBLISH.md) for how they get published.
|
||||
|
||||
## Development
|
||||
|
||||
Requires Node ≥ 22 (the repo pins 24 in `.nvmrc`) and pnpm — the version is
|
||||
pinned by `packageManager`, so `corepack enable` is enough.
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
| Command | Does |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| `pnpm run build` | compiles both packages with `tsc` |
|
||||
| `pnpm run typecheck` | `tsc --build --noEmit` across the workspace |
|
||||
| `pnpm run lint` | Biome check |
|
||||
| `pnpm run lint:fix` | Biome check with fixes applied |
|
||||
| `pnpm test` | vitest, both packages |
|
||||
| `pnpm run test:coverage` | vitest with the coverage gate |
|
||||
| `pnpm run coverage:summary` | renders the last coverage run as a Markdown table |
|
||||
|
||||
`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
|
||||
(`pnpm --filter @bitstack/nopy run nopy`) that executes the TypeScript sources
|
||||
directly through `tsx`.
|
||||
|
||||
## Git hooks
|
||||
|
||||
Installed by `simple-git-hooks` on `pnpm install`, configured in the root
|
||||
`package.json`:
|
||||
|
||||
- **pre-commit** — Biome check with fixes, on staged files only, re-staging what
|
||||
it fixed. Fast; blocks only on problems it cannot fix itself.
|
||||
- **pre-push** — `lint:ci` → `typecheck` → `test:coverage`. This is the same gate
|
||||
CI runs, so a push that survives it will not surprise you on the runner.
|
||||
|
||||
Set `SKIP_SIMPLE_GIT_HOOKS=1` to bypass either one; re-install them after
|
||||
changing the config with `pnpm exec simple-git-hooks`.
|
||||
|
||||
## Coverage
|
||||
|
||||
Both packages hold a hard **85 % branch** floor, enforced by
|
||||
`coverage.thresholds` in their `vitest.config.ts` rather than by a CI-only flag —
|
||||
`pnpm run test:coverage` fails the same way locally, in the `pre-push` hook, and
|
||||
on the runner. Barrel files and CLI argv wiring are excluded; everything with
|
||||
behaviour in it is not.
|
||||
|
||||
+17
-7
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -7,11 +7,9 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["dist", "node_modules", ".yarn", "*.lock"]
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!**/.yarn", "!**/*.lock"]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
@@ -21,7 +19,7 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"preset": "recommended",
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
},
|
||||
@@ -38,5 +36,17 @@
|
||||
"quoteStyle": "single",
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/tests/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"performance": {
|
||||
"noDelete": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
id: 'admin:cockpit',
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { cubes, uniqid } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Manifest for the admin:hostname cube.
|
||||
* This cube allows for setting and persistently changing the system's hostname.
|
||||
*/
|
||||
export default cubes.Manifest({
|
||||
id: 'admin:hostname',
|
||||
name: 'Permanently change the hostname',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
HOSTNAME: z.string().min(1).max(64).describe('The new hostname for the target host')
|
||||
.default(`host-${uniqid()}`),
|
||||
})
|
||||
id: 'admin:hostname',
|
||||
name: 'Permanently change the hostname',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
HOSTNAME: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.describe('The new hostname for the target host')
|
||||
.default(`host-${uniqid()}`),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
id: 'admin:locale',
|
||||
|
||||
@@ -7,6 +7,9 @@ export default cubes.Manifest({
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
UPDATE: z.boolean().describe('Update package cache before installing').default(false),
|
||||
PACKAGES: z.string().describe('Space-separated list of packages to install').default('vim htop curl'),
|
||||
PACKAGES: z
|
||||
.string()
|
||||
.describe('Space-separated list of packages to install')
|
||||
.default('vim htop curl'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||
|
||||
@@ -8,12 +8,18 @@ import { cubes } from '@bitstack/nopy';
|
||||
* Configures a WiFi client connection using NetworkManager (nmcli).
|
||||
*/
|
||||
export default cubes.Manifest({
|
||||
id: 'net:wifi:connection',
|
||||
name: 'network:wifi:connection - Connect to a WiFi network',
|
||||
schema: z.object({
|
||||
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'),
|
||||
AUTOCONNECT: z.boolean().default(true).describe('Whether to automatically connect to this network'),
|
||||
CONNECTION_NAME: z.string().optional().describe('Optional name for the connection (defaults to SSID)'),
|
||||
})
|
||||
id: 'net:wifi:connection',
|
||||
name: 'network:wifi:connection - Connect to a WiFi network',
|
||||
schema: z.object({
|
||||
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'),
|
||||
AUTOCONNECT: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe('Whether to automatically connect to this network'),
|
||||
CONNECTION_NAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional name for the connection (defaults to SSID)'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ export default cubes.Manifest({
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
APP: z.string().describe('The name of the systemd service (e.g., flintstone)'),
|
||||
SERVICE_NAME: z.string().optional().describe('Display name for the service').default('Application'),
|
||||
SERVICE_NAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Display name for the service')
|
||||
.default('Application'),
|
||||
AUTOSTART: z.boolean().describe('Should the service be enabled and started?').default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
// [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
|
||||
|
||||
@@ -8,13 +8,19 @@ import { cubes } from '@bitstack/nopy';
|
||||
* Allows modifying existing user accounts (password, groups).
|
||||
*/
|
||||
export default cubes.Manifest({
|
||||
id: 'user:edit',
|
||||
name: 'user:edit - Modify an existing user account',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
USER: z.string().describe('The username of the account to modify'),
|
||||
PASSWORD: z.string().optional().describe('New password for the user (optional)'),
|
||||
GROUPS: z.string().optional().describe('Comma-separated list of groups the user SHOULD be in (optional)'),
|
||||
GROUPS_ABSENT: z.string().optional().describe('Comma-separated list of groups to REMOVE from the user (optional)'),
|
||||
})
|
||||
id: 'user:edit',
|
||||
name: 'user:edit - Modify an existing user account',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
USER: z.string().describe('The username of the account to modify'),
|
||||
PASSWORD: z.string().optional().describe('New password for the user (optional)'),
|
||||
GROUPS: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Comma-separated list of groups the user SHOULD be in (optional)'),
|
||||
GROUPS_ABSENT: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Comma-separated list of groups to REMOVE from the user (optional)'),
|
||||
}),
|
||||
});
|
||||
|
||||
Generated
-2340
File diff suppressed because it is too large
Load Diff
+17
-25
@@ -1,41 +1,33 @@
|
||||
{
|
||||
"name": "@bitsquare/ansiblings",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72",
|
||||
"engines": {
|
||||
"node": ">=21"
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r run build",
|
||||
"build:clean": "pnpm clean && pnpm build",
|
||||
"build:tsgo": "tsgo --build",
|
||||
"clean": "pnpm -r run clean",
|
||||
"test": "pnpm -r run test",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck:legacy": "tsc --noEmit",
|
||||
"test:coverage": "pnpm -r run test:coverage",
|
||||
"coverage:summary": "node scripts/coverage-summary.mjs",
|
||||
"typecheck": "tsc --build --noEmit",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write ."
|
||||
"lint:ci": "biome ci .",
|
||||
"format": "biome format --write .",
|
||||
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && simple-git-hooks || true"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm exec biome check --write --staged --no-errors-on-unmatched && git update-index --again",
|
||||
"pre-push": "pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@logtape/logtape": "0.8.0",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/node": ">=21",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260303.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^13.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/node": ">=21",
|
||||
"@logtape/logtape": "0.8.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.7.3",
|
||||
"commander": "^13.1.0"
|
||||
}
|
||||
"@biomejs/biome": "^2.5.5",
|
||||
"@logtape/logtape": "^2.2.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { keyman } from './keyman.main.js';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--print-config')) {
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(JSON.stringify(paths));
|
||||
process.exit(0);
|
||||
}
|
||||
keyman();
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
declare const KeymanConfigSchema: z.ZodObject<{
|
||||
vaultRoot: z.ZodDefault<z.ZodString>;
|
||||
keysDir: z.ZodDefault<z.ZodString>;
|
||||
tmpDir: z.ZodDefault<z.ZodString>;
|
||||
ageKeyFile: z.ZodDefault<z.ZodString>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
vaultRoot: string;
|
||||
keysDir: string;
|
||||
tmpDir: string;
|
||||
ageKeyFile: string;
|
||||
}, {
|
||||
vaultRoot?: string | undefined;
|
||||
keysDir?: string | undefined;
|
||||
tmpDir?: string | undefined;
|
||||
ageKeyFile?: string | undefined;
|
||||
}>;
|
||||
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
||||
/**
|
||||
* Resolution strategy for merging config properties
|
||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
||||
* - 'override': Child value completely replaces parent value
|
||||
*/
|
||||
export type ResolutionStrategy = 'merge' | 'override';
|
||||
/**
|
||||
* Resolution configuration for customizing merge behavior
|
||||
*/
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
||||
* 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
|
||||
*/
|
||||
export declare function loadConfig(): KeymanConfig;
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export declare function resolveConfigPaths(config: KeymanConfig): {
|
||||
vaultRoot: string;
|
||||
keysDir: string;
|
||||
tmpDir: string;
|
||||
keyPath: string;
|
||||
};
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export declare function getConfigPaths(): string[];
|
||||
export {};
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
const KeymanConfigSchema = z.object({
|
||||
vaultRoot: z.string().default('vault'),
|
||||
keysDir: z.string().default('keys'),
|
||||
tmpDir: z.string().default('tmp'),
|
||||
ageKeyFile: z.string().default('age.key'),
|
||||
});
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
const CONFIG_FILENAME = '.keymanrc.json';
|
||||
/**
|
||||
* Path-based properties that should be resolved relative to config file location
|
||||
*/
|
||||
const PATH_PROPERTIES = ['vaultRoot'];
|
||||
/**
|
||||
* Resolves path properties in a config object relative to the config file's directory
|
||||
* @param configFile The raw config file contents
|
||||
* @param configDir Directory containing the config file
|
||||
* @returns Config with path properties resolved to absolute paths
|
||||
*/
|
||||
function resolvePathsRelativeToConfig(configFile, configDir) {
|
||||
const resolved = { ...configFile };
|
||||
for (const prop of PATH_PROPERTIES) {
|
||||
const value = configFile[prop];
|
||||
if (typeof value === 'string' && !path.isAbsolute(value)) {
|
||||
resolved[prop] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
* @param startDir Directory to start searching from
|
||||
* @returns Array of paths to .keymanrc.json files
|
||||
*/
|
||||
function findConfigFiles(startDir) {
|
||||
const configPaths = [];
|
||||
let currentDir = startDir;
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(os.homedir(), CONFIG_FILENAME);
|
||||
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
return configPaths;
|
||||
}
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(parentValue, childValue, strategy) {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
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 = { ...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
|
||||
*/
|
||||
function mergeConfigs(parent, childFile) {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result = { ...parent };
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution')
|
||||
continue; // Skip resolution property itself
|
||||
const strategy = resolution[key] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
||||
* 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
|
||||
*/
|
||||
export function loadConfig() {
|
||||
const startDir = process.cwd();
|
||||
const configPaths = findConfigFiles(startDir);
|
||||
if (configPaths.length === 0) {
|
||||
console.error('ℹ️ No .keymanrc.json found, using default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
// Start with defaults and merge each config file
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content);
|
||||
// Resolve path properties relative to the config file's directory
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
console.error(`✅ Loaded configuration from ${configPath}`);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
|
||||
}
|
||||
// Continue with other configs instead of failing entirely
|
||||
}
|
||||
}
|
||||
// Validate the final merged result
|
||||
try {
|
||||
return KeymanConfigSchema.parse(config);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
console.error('ℹ️ Falling back to default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export function resolveConfigPaths(config) {
|
||||
// VAULT_ROOT environment variable takes precedence
|
||||
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
|
||||
return {
|
||||
vaultRoot,
|
||||
keysDir: path.resolve(vaultRoot, config.keysDir),
|
||||
tmpDir: path.resolve(vaultRoot, config.tmpDir),
|
||||
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export function getConfigPaths() {
|
||||
return findConfigFiles(process.cwd());
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
-50
@@ -1,50 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function copyKey(sshDir, tmpDir) {
|
||||
const getKeys = (dir) => {
|
||||
if (!fs.existsSync(dir))
|
||||
return [];
|
||||
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
};
|
||||
const sshKeys = getKeys(sshDir);
|
||||
const tmpKeys = getKeys(tmpDir);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No SSH keys found.');
|
||||
return;
|
||||
}
|
||||
const { selectedKey } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selectedKey',
|
||||
message: 'Select key to copy public key from:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
// Determine location of the public key
|
||||
// Prefer tmpDir if it exists there, otherwise sshDir
|
||||
let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`);
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
pubKeyPath = path.join(sshDir, `${selectedKey}.pub`);
|
||||
}
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
console.error(`❌ Public key not found for ${selectedKey}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
||||
// Detect OS and use appropriate clipboard command
|
||||
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
|
||||
// For this specific request on Darwin:
|
||||
const proc = execa('pbcopy');
|
||||
proc.stdin?.write(pubKeyContent);
|
||||
proc.stdin?.end();
|
||||
await proc;
|
||||
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ Failed to copy to clipboard: ${error}`);
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function decryptKeys(sshDir, vaultDir, ageKey) {
|
||||
const keyDir = path.join(vaultDir, 'keys');
|
||||
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
|
||||
const keyfile = path.join(keyDir, key, `id_${key}.age`);
|
||||
console.log(keyfile);
|
||||
return fs.existsSync(keyfile);
|
||||
});
|
||||
if (vaultKeys.length === 0) {
|
||||
console.log('⚠️ No encrypted keys found.');
|
||||
return;
|
||||
}
|
||||
const { selectedKeys, decryptMode } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select keys to decrypt:',
|
||||
choices: vaultKeys,
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'decryptMode',
|
||||
message: 'Choose decryption location:',
|
||||
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
|
||||
},
|
||||
]);
|
||||
for (const key of selectedKeys) {
|
||||
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
|
||||
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
||||
await execa('cp', [publicKey, publicKeyOut]);
|
||||
await execa('chmod', ['600', privateKeyOut]);
|
||||
console.log(`✅ Decrypted: ${privateKeyOut}`);
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function encryptKeys(sshDir, vaultDir, tmpDir, pubkey) {
|
||||
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])];
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No private SSH keys found to encrypt.');
|
||||
return;
|
||||
}
|
||||
const { selectedKeys } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select SSH keys to encrypt:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
for (const key of selectedKeys) {
|
||||
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`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
|
||||
// Copy public key and create README
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function generateKey(tmpDir, keysDir, pubkey) {
|
||||
const { algorithm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'algorithm',
|
||||
message: 'Select algorithm:',
|
||||
choices: ['ed25519', 'rsa'],
|
||||
default: 'ed25519',
|
||||
},
|
||||
]);
|
||||
const { keyName } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'keyName',
|
||||
message: 'Enter key name:',
|
||||
validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'),
|
||||
},
|
||||
]);
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter passphrase (leave empty for no passphrase):',
|
||||
mask: '*',
|
||||
},
|
||||
]);
|
||||
const { identity } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'identity',
|
||||
message: 'Enter key identity (comment):',
|
||||
},
|
||||
]);
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(`Generating ${algorithm} key pair...`);
|
||||
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) {
|
||||
console.error(`❌ Error generating/encrypting key: ${error}`);
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
-115
@@ -1,115 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export async function listKeys(sshDir, vaultDir, tmpDir) {
|
||||
console.log('\n📂 Checking keys in:');
|
||||
console.log(` SSH: ${sshDir}`);
|
||||
console.log(` Vault: ${vaultDir}`);
|
||||
console.log(` Tmp: ${tmpDir}\n`);
|
||||
const keyMap = new Map();
|
||||
// Scan SSH directory
|
||||
if (fs.existsSync(sshDir)) {
|
||||
const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_'));
|
||||
for (const file of sshFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: !isPub,
|
||||
hasSshPub: isPub,
|
||||
inVault: false,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const key = keyMap.get(keyName);
|
||||
if (isPub) {
|
||||
key.hasSshPub = true;
|
||||
}
|
||||
else {
|
||||
key.inSsh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan tmp directory
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_'));
|
||||
for (const file of tmpFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: false,
|
||||
inTmp: !isPub,
|
||||
hasTmpPub: isPub,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const key = keyMap.get(keyName);
|
||||
if (isPub) {
|
||||
key.hasTmpPub = true;
|
||||
}
|
||||
else {
|
||||
key.inTmp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan vault directory
|
||||
if (fs.existsSync(vaultDir)) {
|
||||
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
||||
const stat = fs.statSync(path.join(vaultDir, dir));
|
||||
return stat.isDirectory();
|
||||
});
|
||||
for (const dir of vaultDirs) {
|
||||
const keyName = `id_${dir}`;
|
||||
const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`);
|
||||
if (fs.existsSync(encryptedPath)) {
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: true,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
keyMap.get(keyName).inVault = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Display results
|
||||
if (keyMap.size === 0) {
|
||||
console.log('⚠️ No SSH keys found.\n');
|
||||
return;
|
||||
}
|
||||
console.log('🔑 SSH Keys:\n');
|
||||
console.log(' Key Name [Vault] [Tmp] [.ssh]');
|
||||
console.log(` ${'─'.repeat(58)}`);
|
||||
const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const key of sortedKeys) {
|
||||
const vaultMark = key.inVault ? '✓' : ' ';
|
||||
const tmpMark = key.inTmp ? '✓' : ' ';
|
||||
const sshMark = key.inSsh ? '✓' : ' ';
|
||||
// Show (.pub) if present in any location
|
||||
const hasPub = key.hasSshPub || key.hasTmpPub;
|
||||
const pubIndicator = hasPub ? ' (.pub)' : '';
|
||||
// Determine status
|
||||
const status = key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ ';
|
||||
const namePart = `${key.name}${pubIndicator}`.padEnd(32);
|
||||
console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`);
|
||||
}
|
||||
console.log('\n Legend:');
|
||||
console.log(' ✅ = Managed (encrypted in vault + active in .ssh)');
|
||||
console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)');
|
||||
console.log(' 🔒 = Encrypted only (in vault, not decrypted)');
|
||||
console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n');
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export declare function keyman(): Promise<void>;
|
||||
Vendored
-79
@@ -1,79 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import inquirer from 'inquirer';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { copyKey } from './keyman.copy.js';
|
||||
import { decryptKeys } from './keyman.decrypt.js';
|
||||
import { encryptKeys } from './keyman.encrypt.js';
|
||||
import { generateKey } from './keyman.generate.js';
|
||||
import { listKeys } from './keyman.list.js';
|
||||
import { extractAgePublicKey } from './keyman.utils.js';
|
||||
// 🔹 Main function to resolve paths and manage flow
|
||||
export async function keyman() {
|
||||
// Load configuration from .keymanrc.json or use defaults
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
|
||||
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
|
||||
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
|
||||
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
|
||||
// Get USER input
|
||||
const { user } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'user',
|
||||
message: 'Specify USER (default: @current):',
|
||||
default: '@current',
|
||||
},
|
||||
]);
|
||||
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
||||
if (!homeDir) {
|
||||
console.error('Error: Unable to determine HOME directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
const sshDir = path.join(homeDir, '.ssh');
|
||||
fs.mkdirSync(paths.vaultRoot, { recursive: true });
|
||||
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
||||
// Main loop - keep showing menu until user quits
|
||||
let running = true;
|
||||
while (running) {
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
// 🔹 Show category selection
|
||||
const { category } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'category',
|
||||
message: 'Select operation:',
|
||||
choices: [
|
||||
{ name: '📋 List keys', value: 'list' },
|
||||
{ name: '📝 Copy public key', value: 'copy' },
|
||||
{ name: '🆕 Generate key', value: 'generate' },
|
||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||
{ name: '❌ Quit', value: 'quit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
switch (category) {
|
||||
case 'list':
|
||||
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
|
||||
break;
|
||||
case 'copy':
|
||||
await copyKey(sshDir, paths.tmpDir);
|
||||
break;
|
||||
case 'generate':
|
||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath));
|
||||
break;
|
||||
case 'encrypt':
|
||||
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, extractAgePublicKey(paths.keyPath));
|
||||
break;
|
||||
case 'decrypt':
|
||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
||||
break;
|
||||
case 'quit':
|
||||
console.log('\n👋 Goodbye!\n');
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export declare function extractAgePublicKey(keyFilePath: string): string | null;
|
||||
Vendored
-21
@@ -1,21 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export function extractAgePublicKey(keyFilePath) {
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||
return publicKeyMatch ? publicKeyMatch[1] : null;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ ERROR: Failed to read key file - ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,67 @@
|
||||
{
|
||||
"name": "@bitstack/keyman",
|
||||
"description": "A system to simplify ssh key management",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "A system to simplify ssh key management",
|
||||
"keywords": [
|
||||
"ssh",
|
||||
"keys",
|
||||
"age",
|
||||
"encryption",
|
||||
"cli"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "bitsquare",
|
||||
"bin": "dist/keyman.cli.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsgo && chmod +x dist/keyman.cli.js && npm link",
|
||||
"build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link",
|
||||
"prepublishOnly": "npm run build",
|
||||
"keyman": "node --loader ts-node/esm src/keyman.bin.ts"
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
|
||||
"directory": "packages/keyman"
|
||||
},
|
||||
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/keyman",
|
||||
"bugs": {
|
||||
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=21.0.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
"bin": {
|
||||
"keyman": "./dist/keyman.cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"keyman": "tsx src/keyman.cli.ts",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"files": ["dist/"],
|
||||
"dependencies": {
|
||||
"execa": "9.5.2",
|
||||
"inquirer": "8.2.4",
|
||||
"ts-node": ">=10.9.1",
|
||||
"typed-dotenv": "10.0.2",
|
||||
"typescript": ">=5.6.3",
|
||||
"zod": "^3.24.1",
|
||||
"zx": "^8.3.0"
|
||||
"execa": "^10.0.0",
|
||||
"inquirer": "^14.0.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
export * from './keyman.main.js';
|
||||
|
||||
@@ -232,7 +232,7 @@ export function loadConfig(): KeymanConfig {
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
error.issues.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { copyKey } from './keyman.copy.js';
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Tests for keyman config discovery, merging and path resolution.
|
||||
*
|
||||
* Real .keymanrc.json files are written into temp directories and cwd is moved
|
||||
* there, because discovery is defined in terms of the real filesystem walk.
|
||||
* os.homedir() is stubbed so the developer's own home config cannot leak in.
|
||||
*/
|
||||
|
||||
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 {
|
||||
getConfigPaths,
|
||||
type KeymanConfigFile,
|
||||
loadConfig,
|
||||
resolveConfigPaths,
|
||||
} from '../src/keyman.config.js';
|
||||
|
||||
const DEFAULTS = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
|
||||
describe('keyman config', () => {
|
||||
let originalCwd: string;
|
||||
let originalVaultRoot: string | undefined;
|
||||
let rootDir: string;
|
||||
let emptyHome: string;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const write = (dir: string, config: KeymanConfigFile | string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.keymanrc.json'),
|
||||
typeof config === 'string' ? config : JSON.stringify(config, null, 2)
|
||||
);
|
||||
};
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
originalVaultRoot = process.env.VAULT_ROOT;
|
||||
delete process.env.VAULT_ROOT;
|
||||
|
||||
rootDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-config-')));
|
||||
emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-home-')));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(emptyHome);
|
||||
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.chdir(rootDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
vi.restoreAllMocks();
|
||||
if (originalVaultRoot === undefined) {
|
||||
delete process.env.VAULT_ROOT;
|
||||
} else {
|
||||
process.env.VAULT_ROOT = originalVaultRoot;
|
||||
}
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
fs.rmSync(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('discovery', () => {
|
||||
it('falls back to defaults when no config file exists', () => {
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
expect(messages(errorSpy)).toContain('No .keymanrc.json found');
|
||||
});
|
||||
|
||||
it('loads the config file in the current directory', () => {
|
||||
write(rootDir, { keysDir: 'my-keys' });
|
||||
|
||||
expect(loadConfig().keysDir).toBe('my-keys');
|
||||
expect(messages(errorSpy)).toContain('Loaded configuration from');
|
||||
});
|
||||
|
||||
it('fills unspecified properties from the defaults', () => {
|
||||
write(rootDir, { keysDir: 'my-keys' });
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.tmpDir).toBe(DEFAULTS.tmpDir);
|
||||
expect(config.ageKeyFile).toBe(DEFAULTS.ageKeyFile);
|
||||
});
|
||||
|
||||
it('lets a child config override its parent', () => {
|
||||
write(rootDir, { keysDir: 'parent-keys', tmpDir: 'parent-tmp' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { keysDir: 'child-keys' });
|
||||
process.chdir(child);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('child-keys');
|
||||
expect(config.tmpDir).toBe('parent-tmp');
|
||||
});
|
||||
|
||||
it('gives the home config the lowest priority', () => {
|
||||
write(emptyHome, { keysDir: 'home-keys', tmpDir: 'home-tmp' });
|
||||
write(rootDir, { keysDir: 'local-keys' });
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('local-keys');
|
||||
expect(config.tmpDir).toBe('home-tmp');
|
||||
});
|
||||
|
||||
it('does not load the home config twice when cwd is the home directory', () => {
|
||||
write(emptyHome, { keysDir: 'home-keys' });
|
||||
process.chdir(emptyHome);
|
||||
|
||||
const homeConfig = path.join(emptyHome, '.keymanrc.json');
|
||||
expect(getConfigPaths().filter((p) => p === homeConfig)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('orders discovered config files parent first', () => {
|
||||
write(rootDir, {});
|
||||
const child = path.join(rootDir, 'a', 'b');
|
||||
write(child, {});
|
||||
process.chdir(child);
|
||||
|
||||
expect(getConfigPaths()).toEqual([
|
||||
path.join(rootDir, '.keymanrc.json'),
|
||||
path.join(child, '.keymanrc.json'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed configs', () => {
|
||||
it('skips a file with invalid JSON and keeps the rest', () => {
|
||||
write(rootDir, { keysDir: 'parent-keys' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, '{ not json');
|
||||
process.chdir(child);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('parent-keys');
|
||||
expect(messages(warnSpy)).toContain('Skipping invalid JSON in');
|
||||
});
|
||||
|
||||
it('skips a config it cannot read at all', () => {
|
||||
// A directory where a file is expected: readFileSync fails with EISDIR,
|
||||
// which is not a SyntaxError.
|
||||
fs.mkdirSync(path.join(rootDir, '.keymanrc.json'));
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
expect(messages(warnSpy)).toContain('Skipping config');
|
||||
expect(messages(warnSpy)).not.toContain('invalid JSON');
|
||||
});
|
||||
|
||||
it('falls back to defaults when the merged config fails validation', () => {
|
||||
write(rootDir, { keysDir: 123 } as unknown as KeymanConfigFile);
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
expect(messages(errorSpy)).toContain('Invalid merged configuration');
|
||||
expect(messages(errorSpy)).toContain('keysDir');
|
||||
expect(messages(errorSpy)).toContain('Falling back to default configuration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path resolution', () => {
|
||||
it('resolves a relative vaultRoot against the config file directory', () => {
|
||||
write(rootDir, { vaultRoot: './secrets' });
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'secrets'));
|
||||
});
|
||||
|
||||
it('resolves a vaultRoot that points above the config file', () => {
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { vaultRoot: '../secrets' });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'secrets'));
|
||||
});
|
||||
|
||||
it('leaves an absolute vaultRoot untouched', () => {
|
||||
write(rootDir, { vaultRoot: '/srv/vault' });
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe('/srv/vault');
|
||||
});
|
||||
|
||||
it('leaves non-path properties alone', () => {
|
||||
write(rootDir, { keysDir: './keys', tmpDir: './tmp' });
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('./keys');
|
||||
expect(config.tmpDir).toBe('./tmp');
|
||||
});
|
||||
|
||||
it('resolves each config file against its own directory', () => {
|
||||
write(rootDir, { vaultRoot: './parent-vault' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, {});
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'parent-vault'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge strategy', () => {
|
||||
it('honours an explicit override strategy', () => {
|
||||
write(rootDir, { vaultRoot: '/parent-vault' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { vaultRoot: '/child-vault', resolution: { vaultRoot: 'override' } });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe('/child-vault');
|
||||
});
|
||||
|
||||
it('never surfaces the resolution key in the loaded config', () => {
|
||||
write(rootDir, { keysDir: 'my-keys', resolution: { keysDir: 'override' } });
|
||||
|
||||
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();
|
||||
|
||||
expect(config).toEqual({ ...DEFAULTS, keysDir: 'my-keys' });
|
||||
});
|
||||
|
||||
it('tolerates and drops object-valued keys the schema does not define', () => {
|
||||
write(rootDir, { extra: { a: 1 } } as unknown as KeymanConfigFile);
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { extra: { a: 2, b: 3 } } as unknown as KeymanConfigFile);
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
});
|
||||
|
||||
it('tolerates arrays of objects, which cannot be de-duplicated', () => {
|
||||
write(rootDir, { extra: [{ a: 1 }] } as unknown as KeymanConfigFile);
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { extra: [{ a: 2 }] } as unknown as KeymanConfigFile);
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConfigPaths', () => {
|
||||
it('places every directory under the vault root', () => {
|
||||
const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: '/srv/vault' });
|
||||
|
||||
expect(paths).toEqual({
|
||||
vaultRoot: '/srv/vault',
|
||||
keysDir: '/srv/vault/keys',
|
||||
tmpDir: '/srv/vault/tmp',
|
||||
keyPath: '/srv/vault/age.key',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a relative vault root against the current directory', () => {
|
||||
const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: 'vault' });
|
||||
|
||||
expect(paths.vaultRoot).toBe(path.join(rootDir, 'vault'));
|
||||
});
|
||||
|
||||
it('lets VAULT_ROOT take precedence over the config', () => {
|
||||
process.env.VAULT_ROOT = '/env/vault';
|
||||
|
||||
const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: '/srv/vault' });
|
||||
|
||||
expect(paths.vaultRoot).toBe('/env/vault');
|
||||
expect(paths.keyPath).toBe('/env/vault/age.key');
|
||||
});
|
||||
|
||||
it('honours absolute sub-directory overrides', () => {
|
||||
const paths = resolveConfigPaths({
|
||||
...DEFAULTS,
|
||||
vaultRoot: '/srv/vault',
|
||||
keysDir: '/elsewhere/keys',
|
||||
});
|
||||
|
||||
expect(paths.keysDir).toBe('/elsewhere/keys');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tests for copyKey.
|
||||
*
|
||||
* inquirer and execa are mocked so nothing touches a TTY or the real
|
||||
* clipboard; the key directories are real temp directories.
|
||||
*/
|
||||
|
||||
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, stdin } = vi.hoisted(() => ({
|
||||
execa: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({ execa }));
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
|
||||
import { copyKey } from '../src/keyman.copy.js';
|
||||
|
||||
describe('copyKey', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let tmpDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const touch = (dir: string, file: string, contents = '') => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), contents);
|
||||
};
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
/** The choices offered by the last inquirer.prompt call. */
|
||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
tmpDir = path.join(root, 'tmp');
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const proc = Object.assign(Promise.resolve({ exitCode: 0 }), { stdin });
|
||||
execa.mockReturnValue(proc);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warns when neither directory exists', async () => {
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(messages(logSpy)).toContain('No SSH keys found.');
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when the directories hold no private keys', async () => {
|
||||
touch(sshDir, 'known_hosts');
|
||||
touch(tmpDir, 'id_prod.pub');
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(messages(logSpy)).toContain('No SSH keys found.');
|
||||
});
|
||||
|
||||
it('offers the keys from both directories without duplicates', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub');
|
||||
touch(tmpDir, 'id_prod');
|
||||
touch(tmpDir, 'id_stage');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
touch(tmpDir, 'id_prod.pub');
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(choices()).toEqual(['id_prod', 'id_stage']);
|
||||
});
|
||||
|
||||
it('copies the trimmed public key from tmp to the clipboard', async () => {
|
||||
touch(tmpDir, 'id_prod');
|
||||
touch(tmpDir, 'id_prod.pub', 'ssh-ed25519 AAAA tmp\n');
|
||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh\n');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(execa).toHaveBeenCalledWith('pbcopy');
|
||||
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA tmp');
|
||||
expect(stdin.end).toHaveBeenCalled();
|
||||
expect(messages(logSpy)).toContain('copied to clipboard');
|
||||
});
|
||||
|
||||
it('falls back to the public key in .ssh', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh\n');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA ssh');
|
||||
});
|
||||
|
||||
it('reports a missing public key without invoking the clipboard', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(messages(errorSpy)).toContain('Public key not found for id_prod');
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a clipboard failure instead of throwing', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
execa.mockImplementation(() => {
|
||||
throw new Error('pbcopy missing');
|
||||
});
|
||||
|
||||
await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined();
|
||||
expect(messages(errorSpy)).toContain('Failed to copy to clipboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tests for decryptKeys.
|
||||
*
|
||||
* age, cp and chmod are all mocked; the assertions cover which keys are
|
||||
* offered and exactly where each decrypted key is written.
|
||||
*/
|
||||
|
||||
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 { decryptKeys } from '../src/keyman.decrypt.js';
|
||||
|
||||
const LOCAL = 'Local (vault/tmp)';
|
||||
const SSH = 'SSH (~/.ssh)';
|
||||
|
||||
describe('decryptKeys', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let vaultDir: string;
|
||||
let keyDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const AGE_KEY = '/vault/age.key';
|
||||
|
||||
/** Creates <vault>/keys/<name>/id_<name>.{age,pub}. */
|
||||
const vaultKey = (name: string) => {
|
||||
const dir = path.join(keyDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `id_${name}.age`), 'ENCRYPTED');
|
||||
fs.writeFileSync(path.join(dir, `id_${name}.pub`), 'PUBLIC');
|
||||
};
|
||||
|
||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||
|
||||
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-decrypt-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
vaultDir = path.join(root, 'vault');
|
||||
keyDir = path.join(vaultDir, 'keys');
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
fs.mkdirSync(sshDir, { recursive: true });
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
execa.mockResolvedValue({ exitCode: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warns when the vault holds no encrypted keys', async () => {
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
expect(messages(logSpy)).toContain('No encrypted keys found.');
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers only directories that actually contain an encrypted key', async () => {
|
||||
vaultKey('prod');
|
||||
fs.mkdirSync(path.join(keyDir, 'empty'), { recursive: true });
|
||||
fs.writeFileSync(path.join(keyDir, 'README.md'), '');
|
||||
prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
expect(choices()).toEqual(['prod']);
|
||||
});
|
||||
|
||||
it('decrypts into the vault tmp directory', async () => {
|
||||
vaultKey('prod');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
const out = path.join(vaultDir, 'tmp', 'id_prod');
|
||||
expect(argsOf('age')).toEqual([
|
||||
'-d',
|
||||
'-i',
|
||||
AGE_KEY,
|
||||
'-o',
|
||||
out,
|
||||
path.join(keyDir, 'prod', 'id_prod.age'),
|
||||
]);
|
||||
expect(argsOf('cp')).toEqual([path.join(keyDir, 'prod', 'id_prod.pub'), `${out}.pub`]);
|
||||
expect(argsOf('chmod')).toEqual(['600', out]);
|
||||
expect(messages(logSpy)).toContain(`Decrypted: ${out}`);
|
||||
});
|
||||
|
||||
it('decrypts into the .ssh directory when asked', async () => {
|
||||
vaultKey('prod');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: SSH });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
const out = path.join(sshDir, 'id_prod');
|
||||
expect(argsOf('age')?.[4]).toBe(out);
|
||||
expect(argsOf('cp')?.[1]).toBe(`${out}.pub`);
|
||||
expect(argsOf('chmod')).toEqual(['600', out]);
|
||||
});
|
||||
|
||||
it('decrypts every selected key', async () => {
|
||||
vaultKey('prod');
|
||||
vaultKey('stage');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['prod', 'stage'], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
// age, cp and chmod for each of the two keys.
|
||||
expect(execa).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('does nothing when the selection is empty', async () => {
|
||||
vaultKey('prod');
|
||||
prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Tests for encryptKeys.
|
||||
*
|
||||
* `age` is mocked out; everything the function does to the filesystem itself
|
||||
* (creating the vault layout, copying public keys) is asserted for real.
|
||||
*/
|
||||
|
||||
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 { encryptKeys } from '../src/keyman.encrypt.js';
|
||||
|
||||
describe('encryptKeys', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let vaultDir: string;
|
||||
let tmpDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const PUBKEY = 'age1recipient';
|
||||
|
||||
const key = (dir: string, name: string, marker: string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, name), `PRIVATE ${marker}`);
|
||||
fs.writeFileSync(path.join(dir, `${name}.pub`), `PUBLIC ${marker}`);
|
||||
};
|
||||
|
||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||
|
||||
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-encrypt-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
vaultDir = path.join(root, 'vault');
|
||||
tmpDir = path.join(root, 'vault', 'tmp');
|
||||
fs.mkdirSync(sshDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// Stand in for `age`: record the call and write the output file.
|
||||
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||
return { exitCode: 0 };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warns when there is nothing to encrypt', async () => {
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores public keys and unrelated files when building the list', async () => {
|
||||
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
|
||||
fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC');
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||
});
|
||||
|
||||
it('offers the keys from .ssh and tmp without duplicates', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
key(tmpDir, 'id_prod', 'tmp');
|
||||
key(tmpDir, 'id_stage', 'tmp');
|
||||
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(choices()).toEqual(['id_prod', 'id_stage']);
|
||||
});
|
||||
|
||||
it('encrypts a key from .ssh into the vault', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
const vaultPath = path.join(vaultDir, 'keys', 'prod');
|
||||
expect(execa).toHaveBeenCalledWith('age', [
|
||||
'-r',
|
||||
PUBKEY,
|
||||
'-o',
|
||||
path.join(vaultPath, 'id_prod.age'),
|
||||
path.join(sshDir, 'id_prod'),
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe('PUBLIC ssh');
|
||||
expect(messages(logSpy)).toContain('Encrypted and stored');
|
||||
});
|
||||
|
||||
it('prefers the tmp copy when a key exists in both directories', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
key(tmpDir, 'id_prod', 'tmp');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
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(
|
||||
'PUBLIC tmp'
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts every selected key', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
key(sshDir, 'id_stage', 'ssh');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(execa).toHaveBeenCalledTimes(2);
|
||||
expect(fs.existsSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.age'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(vaultDir, 'keys', 'stage', 'id_stage.age'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the selection is empty', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Tests for generateKey.
|
||||
*
|
||||
* ssh-keygen and age are mocked; the ssh-keygen mock writes the files the real
|
||||
* binary would produce so the copy-into-vault step has something to work with.
|
||||
*/
|
||||
|
||||
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 { generateKey } from '../src/keyman.generate.js';
|
||||
|
||||
describe('generateKey', () => {
|
||||
let root: string;
|
||||
let tmpDir: string;
|
||||
let keysDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const PUBKEY = 'age1recipient';
|
||||
|
||||
/** Answers each prompt by the name of the question it asks. */
|
||||
const answer = (answers: Record<string, string>) => {
|
||||
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||
const { name } = questions[0];
|
||||
return { [name]: answers[name] ?? '' };
|
||||
});
|
||||
};
|
||||
|
||||
/** The question object from the prompt call for `name`. */
|
||||
const question = (name: string) =>
|
||||
prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name);
|
||||
|
||||
/** The argv of the mocked call to `binary`. */
|
||||
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-generate-')));
|
||||
tmpDir = path.join(root, 'tmp');
|
||||
keysDir = path.join(root, 'keys');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Stand in for the real binaries: ssh-keygen writes a key pair, age is a no-op.
|
||||
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 AAAA generated');
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
});
|
||||
|
||||
answer({ algorithm: 'ed25519', keyName: 'prod', password: 'pw', identity: 'me@host' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('generates the key pair with the answers it collected', async () => {
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(argsOf('ssh-keygen')).toEqual([
|
||||
'-t',
|
||||
'ed25519',
|
||||
'-f',
|
||||
path.join(tmpDir, 'id_prod'),
|
||||
'-N',
|
||||
'pw',
|
||||
'-C',
|
||||
'me@host',
|
||||
]);
|
||||
expect(messages(logSpy)).toContain('Key generated');
|
||||
});
|
||||
|
||||
it('does not prefix a key name that already starts with id_', async () => {
|
||||
answer({ algorithm: 'ed25519', keyName: 'id_prod', password: '', identity: '' });
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod'));
|
||||
});
|
||||
|
||||
it('requests a 4096 bit key for rsa', async () => {
|
||||
answer({ algorithm: 'rsa', keyName: 'prod', password: '', identity: '' });
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(argsOf('ssh-keygen')?.slice(-2)).toEqual(['-b', '4096']);
|
||||
});
|
||||
|
||||
it('rejects an empty key name', async () => {
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
const { validate } = question('keyName');
|
||||
expect(validate(' ')).toBe('Key name cannot be empty');
|
||||
expect(validate('prod')).toBe(true);
|
||||
});
|
||||
|
||||
it('encrypts the new key into the vault and copies the public key', async () => {
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
const vaultPath = path.join(keysDir, 'prod');
|
||||
expect(argsOf('age')).toEqual([
|
||||
'-r',
|
||||
PUBKEY,
|
||||
'-o',
|
||||
path.join(vaultPath, 'id_prod.age'),
|
||||
path.join(tmpDir, 'id_prod'),
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe(
|
||||
'ssh-ed25519 AAAA generated'
|
||||
);
|
||||
expect(messages(logSpy)).toContain('Encrypted and stored');
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing key file', async () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'id_prod'), 'EXISTING');
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(messages(errorSpy)).toContain('Key file id_prod already exists');
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
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 () => {
|
||||
execa.mockRejectedValue(new Error('ssh-keygen exploded'));
|
||||
|
||||
await expect(generateKey(tmpDir, keysDir, PUBKEY)).resolves.toBeUndefined();
|
||||
expect(messages(errorSpy)).toContain('Error generating/encrypting key');
|
||||
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a failure from age', async () => {
|
||||
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||
if (binary === 'age') throw new Error('age exploded');
|
||||
const keyPath = args[args.indexOf('-f') + 1];
|
||||
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated');
|
||||
return { exitCode: 0 };
|
||||
});
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(messages(errorSpy)).toContain('Error generating/encrypting key');
|
||||
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Tests for listKeys.
|
||||
*
|
||||
* listKeys is pure filesystem inspection plus console output, so it runs
|
||||
* against real temp directories and the assertions are made on what it prints.
|
||||
*/
|
||||
|
||||
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 { listKeys } from '../src/keyman.list.js';
|
||||
|
||||
describe('listKeys', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let vaultDir: string;
|
||||
let tmpDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** The single output line describing `name`, without padding noise. */
|
||||
const row = (name: string) =>
|
||||
logSpy.mock.calls
|
||||
.map((c) => c.join(' '))
|
||||
.find((line) => line.includes(`${name} `) || line.includes(`${name}(`))
|
||||
?.replace(/ +/g, ' ');
|
||||
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
const touch = (dir: string, file: string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
|
||||
/** Creates a vault entry: <vault>/<name>/id_<name>.age */
|
||||
const vaultKey = (name: string) => touch(path.join(vaultDir, name), `id_${name}.age`);
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-list-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
vaultDir = path.join(root, 'keys');
|
||||
tmpDir = path.join(root, 'tmp');
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the directories it inspected', async () => {
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain(sshDir);
|
||||
expect(output()).toContain(vaultDir);
|
||||
expect(output()).toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('warns when none of the directories exist', async () => {
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain('No SSH keys found.');
|
||||
expect(output()).not.toContain('SSH Keys:');
|
||||
});
|
||||
|
||||
it('warns when the directories exist but hold no id_ files', async () => {
|
||||
fs.mkdirSync(sshDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
fs.mkdirSync(vaultDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain('No SSH keys found.');
|
||||
});
|
||||
|
||||
it('marks a key present in the vault and in .ssh as managed', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
vaultKey('prod');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_prod')).toContain('✅');
|
||||
expect(row('id_prod')).toContain('[✓] [ ] [✓]');
|
||||
});
|
||||
|
||||
it('marks a key decrypted into tmp as decrypted', async () => {
|
||||
touch(tmpDir, 'id_stage');
|
||||
vaultKey('stage');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_stage')).toContain('🔓');
|
||||
expect(row('id_stage')).toContain('[✓] [✓] [ ]');
|
||||
});
|
||||
|
||||
it('marks a key only present in the vault as encrypted', async () => {
|
||||
vaultKey('cold');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_cold')).toContain('🔒');
|
||||
expect(row('id_cold')).toContain('[✓] [ ] [ ]');
|
||||
});
|
||||
|
||||
it('marks a key missing from the vault as unmanaged', async () => {
|
||||
touch(sshDir, 'id_loose');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_loose')).toContain('⚠️');
|
||||
expect(row('id_loose')).toContain('[ ] [ ] [✓]');
|
||||
});
|
||||
|
||||
it('shows a .pub indicator for a public key found in .ssh', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_prod')).toContain('id_prod (.pub)');
|
||||
});
|
||||
|
||||
it('shows a .pub indicator for a public key found in tmp', async () => {
|
||||
touch(tmpDir, 'id_prod');
|
||||
touch(tmpDir, 'id_prod.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_prod')).toContain('id_prod (.pub)');
|
||||
});
|
||||
|
||||
it('lists a public key with no matching private key', async () => {
|
||||
touch(sshDir, 'id_orphan.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_orphan')).toContain('id_orphan (.pub)');
|
||||
// No private key anywhere, so every column stays blank.
|
||||
expect(row('id_orphan')).toContain('[ ] [ ] [ ]');
|
||||
});
|
||||
|
||||
it('lists a tmp public key with no matching private key', async () => {
|
||||
touch(tmpDir, 'id_orphan.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_orphan')).toContain('id_orphan (.pub)');
|
||||
});
|
||||
|
||||
it('merges the same key seen in .ssh, tmp and the vault', async () => {
|
||||
touch(sshDir, 'id_shared');
|
||||
touch(tmpDir, 'id_shared');
|
||||
touch(tmpDir, 'id_shared.pub');
|
||||
vaultKey('shared');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_shared')).toContain('[✓] [✓] [✓]');
|
||||
// Vault plus .ssh wins over the decrypted-to-tmp status.
|
||||
expect(row('id_shared')).toContain('✅');
|
||||
});
|
||||
|
||||
it('ignores files in the .ssh directory that are not keys', async () => {
|
||||
touch(sshDir, 'config');
|
||||
touch(sshDir, 'known_hosts');
|
||||
touch(sshDir, 'id_real');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).not.toContain('known_hosts');
|
||||
expect(row('id_real')).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores vault directories with no encrypted key inside', async () => {
|
||||
fs.mkdirSync(path.join(vaultDir, 'empty'), { recursive: true });
|
||||
vaultKey('real');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_empty')).toBeUndefined();
|
||||
expect(row('id_real')).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores loose files sitting next to the vault directories', async () => {
|
||||
vaultKey('real');
|
||||
fs.writeFileSync(path.join(vaultDir, 'README.md'), '');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).not.toContain('id_README');
|
||||
});
|
||||
|
||||
it('sorts keys by name', async () => {
|
||||
touch(sshDir, 'id_charlie');
|
||||
touch(sshDir, 'id_alpha');
|
||||
touch(sshDir, 'id_bravo');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
const names = output()
|
||||
.split('\n')
|
||||
.filter((line) => line.includes('id_'))
|
||||
.map((line) => line.match(/id_\w+/)?.[0]);
|
||||
expect(names).toEqual(['id_alpha', 'id_bravo', 'id_charlie']);
|
||||
});
|
||||
|
||||
it('prints the legend once keys are listed', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain('Legend:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Tests for the keyman() menu loop.
|
||||
*
|
||||
* Every operation it dispatches to has its own suite, so they are all mocked
|
||||
* here: what is under test is path resolution, dispatch and the loop itself.
|
||||
*/
|
||||
|
||||
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,
|
||||
loadConfig,
|
||||
resolveConfigPaths,
|
||||
listKeys,
|
||||
copyKey,
|
||||
generateKey,
|
||||
encryptKeys,
|
||||
decryptKeys,
|
||||
extractAgePublicKey,
|
||||
} = vi.hoisted(() => ({
|
||||
prompt: vi.fn(),
|
||||
loadConfig: vi.fn(),
|
||||
resolveConfigPaths: vi.fn(),
|
||||
listKeys: vi.fn(),
|
||||
copyKey: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
encryptKeys: vi.fn(),
|
||||
decryptKeys: vi.fn(),
|
||||
extractAgePublicKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
vi.mock('../src/keyman.config.js', () => ({ loadConfig, resolveConfigPaths }));
|
||||
vi.mock('../src/keyman.list.js', () => ({ listKeys }));
|
||||
vi.mock('../src/keyman.copy.js', () => ({ copyKey }));
|
||||
vi.mock('../src/keyman.generate.js', () => ({ generateKey }));
|
||||
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
|
||||
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
|
||||
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
|
||||
|
||||
import { keyman } from '../src/keyman.main.js';
|
||||
|
||||
describe('keyman', () => {
|
||||
let root: string;
|
||||
let paths: { vaultRoot: string; keysDir: string; tmpDir: string; keyPath: string };
|
||||
let originalHome: string | undefined;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** Answers the leading `user` prompt, then walks the given menu choices. */
|
||||
const menu = (categories: string[], user = '@current') => {
|
||||
const queue = [...categories, 'quit'];
|
||||
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||
const { name } = questions[0];
|
||||
if (name === 'user') return { user };
|
||||
return { category: queue.shift() };
|
||||
});
|
||||
};
|
||||
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalHome = process.env.HOME;
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-main-')));
|
||||
process.env.HOME = path.join(root, 'home');
|
||||
|
||||
paths = {
|
||||
vaultRoot: path.join(root, 'vault'),
|
||||
keysDir: path.join(root, 'vault', 'keys'),
|
||||
tmpDir: path.join(root, 'vault', 'tmp'),
|
||||
keyPath: path.join(root, 'vault', 'age.key'),
|
||||
};
|
||||
loadConfig.mockReturnValue({
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
});
|
||||
resolveConfigPaths.mockReturnValue(paths);
|
||||
extractAgePublicKey.mockReturnValue('age1recipient');
|
||||
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
menu([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints the resolved paths and creates the vault directories', async () => {
|
||||
await keyman();
|
||||
|
||||
expect(output()).toContain(paths.vaultRoot);
|
||||
expect(output()).toContain(paths.keysDir);
|
||||
expect(output()).toContain(paths.keyPath);
|
||||
expect(fs.existsSync(paths.vaultRoot)).toBe(true);
|
||||
expect(fs.existsSync(paths.tmpDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('quits without running any operation', async () => {
|
||||
await keyman();
|
||||
|
||||
expect(output()).toContain('Goodbye!');
|
||||
expect(listKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers every operation in the menu', async () => {
|
||||
await keyman();
|
||||
|
||||
const menuQuestion = prompt.mock.calls.at(-1)?.[0][0] as { choices: { value: string }[] };
|
||||
expect(menuQuestion.choices.map((c) => c.value)).toEqual([
|
||||
'list',
|
||||
'copy',
|
||||
'generate',
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists keys against the .ssh directory of the current user', async () => {
|
||||
menu(['list']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(listKeys).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.keysDir,
|
||||
paths.tmpDir
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a public key', async () => {
|
||||
menu(['copy']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(copyKey).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.tmpDir
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a key with the age recipient from the key file', async () => {
|
||||
menu(['generate']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(extractAgePublicKey).toHaveBeenCalledWith(paths.keyPath);
|
||||
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient');
|
||||
});
|
||||
|
||||
it('encrypts keys into the vault root', async () => {
|
||||
menu(['encrypt']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(encryptKeys).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.vaultRoot,
|
||||
paths.tmpDir,
|
||||
'age1recipient'
|
||||
);
|
||||
});
|
||||
|
||||
it('decrypts keys using the age identity file', async () => {
|
||||
menu(['decrypt']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(decryptKeys).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.vaultRoot,
|
||||
paths.keyPath
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps showing the menu until the user quits', async () => {
|
||||
menu(['list', 'copy', 'list']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(listKeys).toHaveBeenCalledTimes(2);
|
||||
expect(copyKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('targets another user home directory when a user is named', async () => {
|
||||
menu(['list'], 'deploy');
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir);
|
||||
});
|
||||
|
||||
it('aborts when the home directory cannot be determined', async () => {
|
||||
delete process.env.HOME;
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
await expect(keyman()).rejects.toThrow('process.exit');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Tests for extractAgePublicKey.
|
||||
*
|
||||
* Runs against real files in a temp directory: the function is a thin wrapper
|
||||
* around fs plus a regex, and faking fs would only test the fake.
|
||||
*/
|
||||
|
||||
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 { extractAgePublicKey } from '../src/keyman.utils.js';
|
||||
|
||||
describe('extractAgePublicKey', () => {
|
||||
let tmpDir: string;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const keyFile = (contents: string) => {
|
||||
const file = path.join(tmpDir, 'age.key');
|
||||
fs.writeFileSync(file, contents);
|
||||
return file;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-utils-')));
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the public key from a standard age key file', () => {
|
||||
const file = keyFile(
|
||||
[
|
||||
'# created: 2026-01-01T00:00:00Z',
|
||||
'# public key: age1abc123xyz',
|
||||
'AGE-SECRET-KEY-1QQQ',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(extractAgePublicKey(file)).toBe('age1abc123xyz');
|
||||
});
|
||||
|
||||
it('tolerates extra whitespace after the label', () => {
|
||||
const file = keyFile('# public key: age1spaced\n');
|
||||
|
||||
expect(extractAgePublicKey(file)).toBe('age1spaced');
|
||||
});
|
||||
|
||||
it('returns null and reports when the file does not exist', () => {
|
||||
const missing = path.join(tmpDir, 'nope.key');
|
||||
|
||||
expect(extractAgePublicKey(missing)).toBeNull();
|
||||
expect(errorSpy.mock.calls[0][0]).toContain('Age key file not found');
|
||||
});
|
||||
|
||||
it('returns null when the file has no public key line', () => {
|
||||
const file = keyFile('AGE-SECRET-KEY-1QQQ\n');
|
||||
|
||||
expect(extractAgePublicKey(file)).toBeNull();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a key that is not on its own line', () => {
|
||||
const file = keyFile('prefix # public key: age1inline\n');
|
||||
|
||||
expect(extractAgePublicKey(file)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null and reports when the file cannot be read', () => {
|
||||
const asDirectory = path.join(tmpDir, 'age.key');
|
||||
fs.mkdirSync(asDirectory);
|
||||
|
||||
expect(extractAgePublicKey(asDirectory)).toBeNull();
|
||||
expect(errorSpy.mock.calls[0][0]).toContain('Failed to read key file');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2020"],
|
||||
"composite": true,
|
||||
"module": "NodeNext",
|
||||
"types": ["jest", "node"]
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["coverage", "node_modules", "dist"],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
pool: 'forks', // Use forks instead of threads to support process.chdir()
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json-summary', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
// Pure re-export barrel: no logic to cover.
|
||||
'src/index.ts',
|
||||
// Argv wiring only; behaviour lives in the modules it calls.
|
||||
'src/keyman.cli.ts',
|
||||
],
|
||||
thresholds: {
|
||||
branches: 85,
|
||||
functions: 85,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
name: '[apt-all] Test dependencies',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
name: '[apt-more] Test dependencies',
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Dynamic dependency resolution for cubes
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
import type { Variables } from '../nopy.common.js';
|
||||
import type { NopyConfig } from '../nopy.config.js';
|
||||
import type { DeployCall } from '../nopy.executor.js';
|
||||
import { type CubeSession, type NopySession } from '../nopy.session.js';
|
||||
import type { Cube, CubeVariables } from './types.js';
|
||||
/**
|
||||
* Context for the resolution process
|
||||
*/
|
||||
export declare class BuildContext {
|
||||
readonly allCubes: Record<string, Cube>;
|
||||
readonly variables: Variables;
|
||||
readonly session: NopySession;
|
||||
readonly config: NopyConfig;
|
||||
readonly auth: {
|
||||
method: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
readonly options: {
|
||||
useDefaults?: boolean;
|
||||
isSessionReplay?: boolean;
|
||||
};
|
||||
readonly deployCalls: DeployCall[];
|
||||
readonly cubeSessions: CubeSession[];
|
||||
private readonly resolvedCubes;
|
||||
constructor(allCubes: Record<string, Cube>, variables: Variables, session: NopySession, config: NopyConfig, auth: {
|
||||
method: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}, options?: {
|
||||
useDefaults?: boolean;
|
||||
isSessionReplay?: boolean;
|
||||
});
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
resolveCube(cubeId: string, host: string, overrides?: CubeVariables): Promise<void>;
|
||||
/**
|
||||
* Builds and stores a deployment call for a resolved cube
|
||||
*/
|
||||
private buildDeployCall;
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Dynamic dependency resolution for cubes
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { VariableAssignment } from '../nopy.prompts.js';
|
||||
const log = getLogger(['nopy', 'resolution']);
|
||||
/**
|
||||
* Context for the resolution process
|
||||
*/
|
||||
export class BuildContext {
|
||||
allCubes;
|
||||
variables;
|
||||
session;
|
||||
config;
|
||||
auth;
|
||||
options;
|
||||
deployCalls = [];
|
||||
cubeSessions = [];
|
||||
resolvedCubes = new Set();
|
||||
constructor(allCubes, variables, session, config, auth, options = {}) {
|
||||
this.allCubes = allCubes;
|
||||
this.variables = variables;
|
||||
this.session = session;
|
||||
this.config = config;
|
||||
this.auth = auth;
|
||||
this.options = options;
|
||||
}
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
async resolveCube(cubeId, host, overrides = {}) {
|
||||
const cube = this.allCubes[cubeId];
|
||||
if (!cube) {
|
||||
throw new Error(`Cube not found: ${cubeId}`);
|
||||
}
|
||||
log.debug('Resolving cube', { cubeId, host });
|
||||
// 1. Assign overrides and defaults
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
this.variables.assign(cubeId, 'params', overrides);
|
||||
}
|
||||
this.variables.assign(cubeId, 'defaults', cube.getDefaults());
|
||||
// 2. Variable collection
|
||||
if (this.options.isSessionReplay) {
|
||||
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
|
||||
if (sessionCube) {
|
||||
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await VariableAssignment(cube, this.variables);
|
||||
}
|
||||
const currentVars = this.variables.get(cubeId);
|
||||
const hookCtx = {
|
||||
exec: (id, vars) => this.resolveCube(id, host, vars),
|
||||
};
|
||||
// 3. Execute 'before' hooks
|
||||
if (cube.manifest.before) {
|
||||
for (const hook of cube.manifest.before) {
|
||||
await hook(hookCtx, currentVars);
|
||||
}
|
||||
}
|
||||
// 4. Resolve dynamic dependencies
|
||||
const depSpecs = cube.manifest.dependencies?.(currentVars) ?? [];
|
||||
for (const spec of depSpecs) {
|
||||
const depId = typeof spec === 'string' ? spec : spec[0];
|
||||
const depVars = typeof spec === 'string' ? {} : (spec[1] ?? {});
|
||||
await this.resolveCube(depId, host, depVars);
|
||||
}
|
||||
// 5. Build the deployment call
|
||||
this.buildDeployCall(cube, host);
|
||||
// 6. Execute 'after' hooks
|
||||
if (cube.manifest.after) {
|
||||
for (const hook of cube.manifest.after) {
|
||||
await hook(hookCtx, currentVars);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds and stores a deployment call for a resolved cube
|
||||
*/
|
||||
buildDeployCall(cube, host) {
|
||||
const cubeId = cube.id;
|
||||
const callKey = `${cubeId}:${host}`;
|
||||
if (this.resolvedCubes.has(callKey))
|
||||
return;
|
||||
const parts = [];
|
||||
if (this.auth.method === 'password' && this.auth.username && this.auth.password) {
|
||||
parts.push(`--user ${this.auth.username} --password ${this.auth.password}`);
|
||||
}
|
||||
const cubeVars = this.variables.get(cubeId);
|
||||
Object.entries(cubeVars).forEach(([key, value]) => {
|
||||
parts.push(`--data "${key}=${value}"`);
|
||||
});
|
||||
parts.push(`--chdir ${cube.dir}`);
|
||||
parts.push(`${cube.dir}/${cube.deployScript}`);
|
||||
const command = ['pyinfra', host, '-y', ...parts];
|
||||
this.deployCalls.push({
|
||||
cube: cubeId,
|
||||
host,
|
||||
cwd: cube.dir,
|
||||
command,
|
||||
env: cubeVars,
|
||||
dependencies: [],
|
||||
});
|
||||
if (!this.cubeSessions.some(s => s.key === cubeId)) {
|
||||
this.cubeSessions.push({
|
||||
key: cubeId,
|
||||
variables: this.variables.get(cubeId, 'prompts'),
|
||||
});
|
||||
}
|
||||
this.resolvedCubes.add(callKey);
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module cubes/factories
|
||||
*/
|
||||
import { Manifest } from './types.js';
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
*
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export declare function createManifest<Schema extends import('zod').z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export declare const manifest: typeof createManifest;
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export declare const ManifestFactory: typeof createManifest;
|
||||
export { Manifest } from './types.js';
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module cubes/factories
|
||||
*/
|
||||
import { Manifest } from './types.js';
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
*
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export function createManifest(opts) {
|
||||
return Manifest(opts);
|
||||
}
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export const manifest = createManifest;
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export const ManifestFactory = createManifest;
|
||||
export { Manifest } from './types.js';
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Nopy Cubes Module
|
||||
*
|
||||
* Self-contained deployment units for pyinfra automation.
|
||||
*
|
||||
* @module cubes
|
||||
*/
|
||||
export { Cube, Manifest, } from './types.js';
|
||||
export type { Hook, HookContext, LoadResult, CubeVariables, DependencySpec, } from './types.js';
|
||||
export { createManifest, manifest, } from './factories.js';
|
||||
export { loadCubes, findCubeDirectories, getCube, } from './loader.js';
|
||||
export { BuildContext, } from './dependencies.js';
|
||||
export { uniqid } from './utils.js';
|
||||
Vendored
-17
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Nopy Cubes Module
|
||||
*
|
||||
* Self-contained deployment units for pyinfra automation.
|
||||
*
|
||||
* @module cubes
|
||||
*/
|
||||
// Types
|
||||
export { Cube, Manifest, } from './types.js';
|
||||
// Factory functions
|
||||
export { createManifest, manifest, } from './factories.js';
|
||||
// Loader
|
||||
export { loadCubes, findCubeDirectories, getCube, } from './loader.js';
|
||||
// Dependencies
|
||||
export { BuildContext, } from './dependencies.js';
|
||||
// Utilities
|
||||
export { uniqid } from './utils.js';
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Cube discovery and loading from the filesystem
|
||||
* @module cubes/loader
|
||||
*/
|
||||
import { Cube, type LoadResult } from './types.js';
|
||||
/**
|
||||
* Traverses upwards from the current working directory to the root
|
||||
* and collects all directories that contain a `.npcubes` marker file.
|
||||
*
|
||||
* Also includes directories specified in the `.nopyrc.json` configuration.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
*/
|
||||
export declare function findCubeDirectories(): string[];
|
||||
/**
|
||||
* Loads all cubes from discovered cube directories.
|
||||
*/
|
||||
export declare function loadCubes(): Promise<LoadResult>;
|
||||
/**
|
||||
* Gets information about a single cube by name.
|
||||
*/
|
||||
export declare function getCube(cubeName: string): Promise<Cube | undefined>;
|
||||
Vendored
-102
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Cube discovery and loading from the filesystem
|
||||
* @module cubes/loader
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { fs } from 'zx';
|
||||
import { loadConfig } from '../nopy.config.js';
|
||||
import { Cube } from './types.js';
|
||||
/**
|
||||
* Traverses upwards from the current working directory to the root
|
||||
* and collects all directories that contain a `.npcubes` marker file.
|
||||
*
|
||||
* Also includes directories specified in the `.nopyrc.json` configuration.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
*/
|
||||
export function findCubeDirectories() {
|
||||
let currentDir = process.cwd();
|
||||
const config = loadConfig();
|
||||
const dirSet = new Set(config.cubeDirs.map((dir) => path.resolve(process.cwd(), dir)));
|
||||
while (true) {
|
||||
const targetFile = path.join(currentDir, '.npcubes');
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
|
||||
dirSet.add(currentDir);
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Stop when reaching the root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
return [...dirSet];
|
||||
}
|
||||
/**
|
||||
* Extracts cube ID from name pattern [id] or explicit id field
|
||||
*/
|
||||
function extractCubeId(manifest) {
|
||||
if (manifest.id)
|
||||
return manifest.id;
|
||||
const match = manifest.name.match(/^\[([^\]]+)\]/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
/**
|
||||
* Loads all cubes from discovered cube directories.
|
||||
*/
|
||||
export async function loadCubes() {
|
||||
const cubesFolders = findCubeDirectories();
|
||||
const cubes = {};
|
||||
const errors = [];
|
||||
async function scanDirectory(currentDir, baseDir) {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
const manifestFile = files.find((f) => f.name === 'manifest.mjs' || f.name.endsWith('.manifest.mjs'));
|
||||
const deployFile = files.find((f) => f.name === 'deploy.py' || f.name.endsWith('.deploy.py'));
|
||||
if (manifestFile && deployFile) {
|
||||
const cubePath = currentDir;
|
||||
const manifestPath = path.join(cubePath, manifestFile.name);
|
||||
try {
|
||||
const manifest = (await import(manifestPath)).default;
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
errors.push(`Invalid manifest export in ${manifestPath}`);
|
||||
}
|
||||
else if (!manifest.name) {
|
||||
errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`);
|
||||
}
|
||||
else {
|
||||
const cubeId = extractCubeId(manifest) || path.basename(cubePath);
|
||||
if (cubes[cubeId]) {
|
||||
errors.push(`Duplicate cube id '${cubeId}'`);
|
||||
return;
|
||||
}
|
||||
// Ensure basic properties
|
||||
manifest.id = cubeId;
|
||||
manifest.schema = manifest.schema ?? z.object({});
|
||||
cubes[cubeId] = new Cube(manifest, cubePath, deployFile.name);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
errors.push(`Failed to load manifest ${manifestPath}: ${err}`);
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
await scanDirectory(path.join(currentDir, entry.name), baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(cubesFolders.map(async (folder) => {
|
||||
if (fs.existsSync(folder)) {
|
||||
await scanDirectory(folder, folder);
|
||||
}
|
||||
}));
|
||||
return { cubes, errors };
|
||||
}
|
||||
/**
|
||||
* Gets information about a single cube by name.
|
||||
*/
|
||||
export async function getCube(cubeName) {
|
||||
const { cubes } = await loadCubes();
|
||||
return cubes[cubeName];
|
||||
}
|
||||
Vendored
-74
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module cubes/types
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Variables that can be passed to a cube
|
||||
*/
|
||||
export type CubeVariables = Record<string, string | number | boolean>;
|
||||
/**
|
||||
* A dependency specification
|
||||
*/
|
||||
export type DependencySpec = string | [id: string, variables?: CubeVariables];
|
||||
/**
|
||||
* Context passed to cube hooks for executing other cubes
|
||||
*/
|
||||
export interface HookContext {
|
||||
exec: (key: string, variables: CubeVariables) => Promise<void> | void;
|
||||
}
|
||||
/**
|
||||
* Hook function type for before/after cube execution
|
||||
*/
|
||||
export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (ctx: HookContext, variables: z.infer<Schema>) => void | Promise<void>;
|
||||
/**
|
||||
* User-defined specification for a cube
|
||||
*/
|
||||
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
/** Unique identifier for the cube (used for dependency references) */
|
||||
id: string;
|
||||
/** Human-readable name of the cube */
|
||||
name: string;
|
||||
/** Zod schema for validating cube variables */
|
||||
schema: Schema;
|
||||
/** Dynamic dependency resolver based on collected variables */
|
||||
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
|
||||
/** Hooks to run before cube execution */
|
||||
before?: Hook<Schema>[];
|
||||
/** Hooks to run after cube execution */
|
||||
after?: Hook<Schema>[];
|
||||
}
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export declare function Manifest<Schema extends z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
|
||||
export declare namespace Manifest {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
function create<Schema extends z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
|
||||
}
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export declare class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
readonly manifest: Manifest<Schema>;
|
||||
readonly dir: string;
|
||||
readonly deployScript: string;
|
||||
constructor(manifest: Manifest<Schema>, dir: string, deployScript: string);
|
||||
get id(): string;
|
||||
get name(): string;
|
||||
/**
|
||||
* Returns default values for the cube's schema
|
||||
*/
|
||||
getDefaults(): z.infer<Schema>;
|
||||
}
|
||||
/**
|
||||
* Result of loading cubes from the filesystem
|
||||
*/
|
||||
export interface LoadResult {
|
||||
/** Map of cube key to Cube object */
|
||||
cubes: Record<string, Cube>;
|
||||
/** List of errors encountered during loading */
|
||||
errors: string[];
|
||||
}
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module cubes/types
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export function Manifest(opts) {
|
||||
return {
|
||||
id: opts.id ?? '',
|
||||
name: opts.name,
|
||||
schema: opts.schema ?? z.object({}),
|
||||
dependencies: opts.dependencies,
|
||||
before: opts.before ?? [],
|
||||
after: opts.after ?? [],
|
||||
};
|
||||
}
|
||||
(function (Manifest) {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
function create(opts) {
|
||||
return Manifest(opts);
|
||||
}
|
||||
Manifest.create = create;
|
||||
})(Manifest || (Manifest = {}));
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export class Cube {
|
||||
manifest;
|
||||
dir;
|
||||
deployScript;
|
||||
constructor(manifest, dir, deployScript) {
|
||||
this.manifest = manifest;
|
||||
this.dir = dir;
|
||||
this.deployScript = deployScript;
|
||||
}
|
||||
get id() {
|
||||
return this.manifest.id;
|
||||
}
|
||||
get name() {
|
||||
return this.manifest.name;
|
||||
}
|
||||
/**
|
||||
* Returns default values for the cube's schema
|
||||
*/
|
||||
getDefaults() {
|
||||
try {
|
||||
return this.manifest.schema.parse({});
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module cubes/utils
|
||||
*/
|
||||
/**
|
||||
* Generates a random string of the specified length using the current nanotime as a seed.
|
||||
*
|
||||
* Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time.
|
||||
* Suitable for generating unique identifiers, not for cryptographic purposes.
|
||||
*
|
||||
* @param length - The desired length of the random string (default: 5)
|
||||
* @returns A random alphanumeric string of the specified length
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const id = uniqid(); // e.g., "Kx7Pm"
|
||||
* const longId = uniqid(10); // e.g., "Kx7PmQr2Yw"
|
||||
* ```
|
||||
*/
|
||||
export declare function uniqid(length?: number): string;
|
||||
Vendored
-33
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module cubes/utils
|
||||
*/
|
||||
/**
|
||||
* Generates a random string of the specified length using the current nanotime as a seed.
|
||||
*
|
||||
* Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time.
|
||||
* Suitable for generating unique identifiers, not for cryptographic purposes.
|
||||
*
|
||||
* @param length - The desired length of the random string (default: 5)
|
||||
* @returns A random alphanumeric string of the specified length
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const id = uniqid(); // e.g., "Kx7Pm"
|
||||
* const longId = uniqid(10); // e.g., "Kx7PmQr2Yw"
|
||||
* ```
|
||||
*/
|
||||
export function uniqid(length = 5) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charsetLength = charset.length;
|
||||
// Use process.hrtime.bigint() for high-resolution time in nanoseconds
|
||||
let seed = Number(process.hrtime.bigint() % BigInt(Number.MAX_SAFE_INTEGER));
|
||||
const randomString = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Simple linear congruential generator (LCG) for pseudo-randomness
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
const index = seed % charsetLength;
|
||||
randomString.push(charset[index]);
|
||||
}
|
||||
return randomString.join('');
|
||||
}
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Nopy - A CLI tool for pyinfra script management and execution
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export * from './cubes/index.js';
|
||||
export { cubes } from './nopy.cubes.js';
|
||||
export { nopy } from './nopy.main.js';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js';
|
||||
export type { DeployCall, ExecutionResult, ExecutionOptions, } from './nopy.executor.js';
|
||||
export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js';
|
||||
export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js';
|
||||
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
|
||||
export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js';
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
|
||||
export type { NopyConfig, NopyConfigFile, LogConfig, LogVerbosity, HistoryConfig, ExecutionConfig, ResolutionStrategy, ResolutionConfig, } from './nopy.config.js';
|
||||
Vendored
-23
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Nopy - A CLI tool for pyinfra script management and execution
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
// Cubes module
|
||||
export * from './cubes/index.js';
|
||||
// Backwards compatibility - cubes namespace
|
||||
export { cubes } from './nopy.cubes.js';
|
||||
// Main entry point
|
||||
export { nopy } from './nopy.main.js';
|
||||
// Executor
|
||||
export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js';
|
||||
// Workflow
|
||||
export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js';
|
||||
// Prompts
|
||||
export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js';
|
||||
// Session management
|
||||
export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js';
|
||||
// History management
|
||||
export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js';
|
||||
// Configuration
|
||||
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Nopy CLI - pyinfra deployment management
|
||||
* @module nopy.cli
|
||||
*/
|
||||
export {};
|
||||
Vendored
-127
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Nopy CLI - pyinfra deployment management
|
||||
* @module nopy.cli
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from './nopy.config.js';
|
||||
import { clearHistory, formatHistoryList, getLastSession, getSessionById, listHistory, } from './nopy.history.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
const program = new Command();
|
||||
const config = loadConfig();
|
||||
program
|
||||
.name('nopy')
|
||||
.version('1.0.0')
|
||||
.description('A CLI tool for pyinfra script management and execution.')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
$ nopy Interactive cube selection and deployment
|
||||
$ nopy -R Repeat the last deployment session
|
||||
$ nopy -H <id> Run a specific session from history
|
||||
$ nopy -l session.json Load and replay a saved session file
|
||||
$ nopy -s session.json Save session to file after deployment
|
||||
$ nopy -n Dry run (show plan without executing)
|
||||
$ nopy -P Print deploy commands only
|
||||
$ nopy history List all saved sessions
|
||||
$ nopy clear-history Clear session history
|
||||
|
||||
Session Replay:
|
||||
Sessions are automatically saved to history after each deployment.
|
||||
Use 'nopy history' to see available sessions and their IDs.
|
||||
Use 'nopy -R' to quickly repeat the last session.
|
||||
Use 'nopy -H <id>' to run any session from history.
|
||||
`);
|
||||
program
|
||||
.command('install', { isDefault: true })
|
||||
.description('Install cubes on a given host')
|
||||
.alias('i')
|
||||
.option('-D, --use-defaults', 'Run cubes with default values without prompts')
|
||||
.option('-K, --auth-method-key', 'Use SSH key authentication')
|
||||
.option('-R, --repeat-last', 'Repeat the last session from history')
|
||||
.option('-H, --history <id>', 'Run a specific session from history by ID')
|
||||
.option('-s, --save-session <path>', 'Save session to file for later replay')
|
||||
.option('-l, --load-session <path>', 'Load and replay session from file')
|
||||
.option('-n, --dry-run', 'Show execution plan without running')
|
||||
.option('-P, --print-only', 'Print deploy commands and exit (no execution)')
|
||||
.option('-c, --continue-on-error', 'Continue executing after failures')
|
||||
.option('-j, --json', 'Output results as JSON')
|
||||
.option('--no-history', 'Do not save this session to history')
|
||||
.action(async (options) => {
|
||||
// Apply config defaults
|
||||
const execConfig = config.execution ?? {};
|
||||
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
|
||||
try {
|
||||
// Handle session replay
|
||||
const loadSessionPath = options.loadSession;
|
||||
let sessionToReplay;
|
||||
if (options.repeatLast) {
|
||||
const lastEntry = getLastSession();
|
||||
if (!lastEntry) {
|
||||
console.error('No sessions in history. Run a deployment first.');
|
||||
process.exit(1);
|
||||
}
|
||||
sessionToReplay = lastEntry;
|
||||
console.log(`Repeating: ${lastEntry.name}\n`);
|
||||
}
|
||||
else if (options.history) {
|
||||
const entry = getSessionById(options.history);
|
||||
if (!entry) {
|
||||
console.error(`Session not found: ${options.history}`);
|
||||
console.error('Use "nopy history" to list available sessions.');
|
||||
process.exit(1);
|
||||
}
|
||||
sessionToReplay = entry;
|
||||
console.log(`Running: ${entry.name}\n`);
|
||||
}
|
||||
const result = await nopy({
|
||||
useDefaults: options.useDefaults,
|
||||
useAuthKey: options.authMethodKey,
|
||||
saveSession: options.saveSession,
|
||||
loadSession: loadSessionPath,
|
||||
replaySession: sessionToReplay?.session,
|
||||
dryRun: options.dryRun,
|
||||
printOnly: options.printOnly,
|
||||
continueOnError,
|
||||
jsonOutput: options.json,
|
||||
saveToHistory: options.history !== false && !options.dryRun,
|
||||
});
|
||||
// Exit with error code if deployment failed
|
||||
if (result && !result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error('Error:', error instanceof Error ? error.message : error, error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('history')
|
||||
.description('List session history')
|
||||
.alias('h')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action((options) => {
|
||||
const entries = listHistory();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(formatHistoryList(entries));
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('clear-history')
|
||||
.description('Clear all session history')
|
||||
.action(() => {
|
||||
clearHistory();
|
||||
console.log('Session history cleared.');
|
||||
});
|
||||
program.parse();
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Environment variable configuration
|
||||
*/
|
||||
export type TVariables = Record<string, string | number | boolean>;
|
||||
export declare namespace Variables {
|
||||
type ArtefactId = string;
|
||||
type Scope = 'defaults' | 'prompts' | 'params';
|
||||
}
|
||||
export declare class Variables {
|
||||
readonly global: TVariables;
|
||||
/** @summary env as configured in cube or session script */
|
||||
defaults: Record<Variables.ArtefactId, TVariables>;
|
||||
/** @summary env as configured via prompts */
|
||||
prompts: Record<Variables.ArtefactId, TVariables>;
|
||||
/** @summary env as handed via params (on hook calls) */
|
||||
params: Record<Variables.ArtefactId, TVariables>;
|
||||
constructor(global?: TVariables);
|
||||
assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values?: TVariables): void;
|
||||
get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables;
|
||||
}
|
||||
Vendored
-32
@@ -1,32 +0,0 @@
|
||||
export class Variables {
|
||||
global;
|
||||
/** @summary env as configured in cube or session script */
|
||||
defaults = {};
|
||||
/** @summary env as configured via prompts */
|
||||
prompts = {};
|
||||
/** @summary env as handed via params (on hook calls) */
|
||||
params = {};
|
||||
constructor(global = {}) {
|
||||
this.global = global;
|
||||
}
|
||||
assign(artefactId, scope, values = {}) {
|
||||
console.log('Assigning', artefactId, scope, values);
|
||||
if (!this[scope][artefactId]) {
|
||||
this[scope][artefactId] = values;
|
||||
}
|
||||
else {
|
||||
Object.assign(this[scope][artefactId], values);
|
||||
}
|
||||
}
|
||||
get(artefactId, scope) {
|
||||
if (scope) {
|
||||
return this[scope][artefactId] || {};
|
||||
}
|
||||
return {
|
||||
...this.global,
|
||||
...this.defaults[artefactId],
|
||||
...this.prompts[artefactId],
|
||||
...this.params[artefactId],
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
-114
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
import type { TVariables } from './nopy.common.js';
|
||||
/**
|
||||
* Log verbosity levels for pyinfra output
|
||||
*/
|
||||
export type LogVerbosity = 'silent' | 'info' | 'verbose' | 'trace';
|
||||
/**
|
||||
* Logging configuration
|
||||
*/
|
||||
export interface LogConfig {
|
||||
/** Output verbosity level */
|
||||
verbosity?: LogVerbosity;
|
||||
/** Enable pyinfra debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
/**
|
||||
* History configuration
|
||||
*/
|
||||
export interface HistoryConfig {
|
||||
/** Maximum number of sessions to keep in history (default: 10) */
|
||||
maxSessions?: number;
|
||||
/** Whether to auto-save sessions to history (default: true) */
|
||||
autoSave?: boolean;
|
||||
}
|
||||
/**
|
||||
* Execution configuration
|
||||
*/
|
||||
export interface ExecutionConfig {
|
||||
/** Continue executing after a cube fails (default: false) */
|
||||
continueOnError?: boolean;
|
||||
}
|
||||
/**
|
||||
* Resolution strategy for merging config properties
|
||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
||||
* - 'override': Child value completely replaces parent value
|
||||
*/
|
||||
export type ResolutionStrategy = 'merge' | 'override';
|
||||
/**
|
||||
* Resolution configuration for customizing merge behavior
|
||||
*/
|
||||
export type ResolutionConfig = {
|
||||
[K in keyof NopyConfig]?: ResolutionStrategy;
|
||||
};
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface NopyConfigFile extends Partial<NopyConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: ResolutionConfig;
|
||||
}
|
||||
/**
|
||||
* Nopy configuration file structure
|
||||
*/
|
||||
export interface NopyConfig {
|
||||
/** Available host addresses */
|
||||
hosts: string[];
|
||||
/** Directories to search for cubes */
|
||||
cubeDirs: string[];
|
||||
/** Global environment variables */
|
||||
env: TVariables;
|
||||
/** Logging configuration */
|
||||
log?: LogConfig;
|
||||
/** Session history configuration */
|
||||
history?: HistoryConfig;
|
||||
/** Execution configuration */
|
||||
execution?: ExecutionConfig;
|
||||
}
|
||||
/**
|
||||
* Loads the nopy configuration
|
||||
*
|
||||
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "hosts": ["local-host"],
|
||||
* "resolution": {
|
||||
* "hosts": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns The merged configuration
|
||||
* @throws Error if no config file is found
|
||||
*/
|
||||
export declare function loadConfig(): NopyConfig;
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
*/
|
||||
export declare function getConfigPaths(): string[];
|
||||
/**
|
||||
* Saves configuration to a file
|
||||
*
|
||||
* @param data - Configuration data to save
|
||||
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
|
||||
*/
|
||||
export declare function saveConfig(data: Partial<NopyConfig>, configPath?: string): void;
|
||||
/**
|
||||
* Converts log configuration to pyinfra command line flags
|
||||
*
|
||||
* @param logConfig - Log configuration with verbosity and debug settings
|
||||
* @returns Array of pyinfra flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
* // Returns: ['-vv', '--debug']
|
||||
* ```
|
||||
*/
|
||||
export declare function logConfigToFlags(logConfig?: LogConfig): string[];
|
||||
Vendored
-263
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
hosts: [],
|
||||
cubeDirs: [],
|
||||
env: {},
|
||||
};
|
||||
const CONFIG_FILENAME = '.nopyrc.json';
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
*/
|
||||
function findConfigFiles() {
|
||||
const configPaths = [];
|
||||
let currentDir = process.cwd();
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(process.env.HOME || '', CONFIG_FILENAME);
|
||||
if (homeConfig && fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
return configPaths;
|
||||
}
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(parentValue, childValue, strategy) {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
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 = { ...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;
|
||||
}
|
||||
/**
|
||||
* Checks if a string looks like a relative path
|
||||
*/
|
||||
function isRelativePath(value) {
|
||||
return (value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
// Also match paths without ./ prefix that don't look like URLs or absolute paths
|
||||
(!value.startsWith('/') &&
|
||||
!value.startsWith('~') &&
|
||||
!value.includes('://') &&
|
||||
(value.includes('/') || value.endsWith('.json') || value.endsWith('.yml'))));
|
||||
}
|
||||
/**
|
||||
* Resolves relative paths in a value based on config file location
|
||||
*/
|
||||
function resolveRelativePaths(value, configDir) {
|
||||
if (typeof value === 'string') {
|
||||
if (isRelativePath(value)) {
|
||||
return path.resolve(configDir, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveRelativePaths(item, configDir));
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const result = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = resolveRelativePaths(val, configDir);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* Properties that contain filesystem paths and should have relative paths resolved
|
||||
*/
|
||||
const PATH_PROPERTIES = ['cubeDirs'];
|
||||
/**
|
||||
* Resolves relative paths in a config file based on its location
|
||||
* Only resolves paths for properties that are known to contain filesystem paths
|
||||
*/
|
||||
function resolveConfigPaths(config, configPath) {
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolved = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'resolution') {
|
||||
// Don't resolve the resolution config itself
|
||||
resolved[key] = value;
|
||||
}
|
||||
else if (PATH_PROPERTIES.includes(key)) {
|
||||
// Only resolve paths for known path properties
|
||||
resolved[key] = resolveRelativePaths(value, configDir);
|
||||
}
|
||||
else {
|
||||
// Copy other properties as-is (including hosts)
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent, childFile) {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result = { ...parent };
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution')
|
||||
continue; // Skip resolution property itself
|
||||
const strategy = resolution[key] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Loads the nopy configuration
|
||||
*
|
||||
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "hosts": ["local-host"],
|
||||
* "resolution": {
|
||||
* "hosts": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns The merged configuration
|
||||
* @throws Error if no config file is found
|
||||
*/
|
||||
export function loadConfig() {
|
||||
const configPaths = findConfigFiles();
|
||||
if (configPaths.length === 0) {
|
||||
throw new Error(`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`);
|
||||
}
|
||||
// Start with defaults and merge each config file
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content);
|
||||
// Resolve relative paths based on config file location
|
||||
const resolvedConfig = resolveConfigPaths(rawConfig, configPath);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to load config ${configPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
*/
|
||||
export function getConfigPaths() {
|
||||
return findConfigFiles();
|
||||
}
|
||||
/**
|
||||
* Saves configuration to a file
|
||||
*
|
||||
* @param data - Configuration data to save
|
||||
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
|
||||
*/
|
||||
export function saveConfig(data, configPath) {
|
||||
const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME);
|
||||
// Try to load existing config from this specific file
|
||||
let existing = {};
|
||||
if (fs.existsSync(savePath)) {
|
||||
try {
|
||||
existing = JSON.parse(fs.readFileSync(savePath, 'utf-8'));
|
||||
}
|
||||
catch {
|
||||
// Ignore parse errors, start fresh
|
||||
}
|
||||
}
|
||||
const merged = { ...existing, ...data };
|
||||
fs.writeFileSync(savePath, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
/**
|
||||
* Converts log configuration to pyinfra command line flags
|
||||
*
|
||||
* @param logConfig - Log configuration with verbosity and debug settings
|
||||
* @returns Array of pyinfra flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
* // Returns: ['-vv', '--debug']
|
||||
* ```
|
||||
*/
|
||||
export function logConfigToFlags(logConfig) {
|
||||
const flags = [];
|
||||
const verbosity = logConfig?.verbosity ?? 'silent';
|
||||
// Add verbosity flags
|
||||
switch (verbosity) {
|
||||
case 'silent':
|
||||
// No verbosity flags
|
||||
break;
|
||||
case 'info':
|
||||
flags.push('-v'); // Print meta information
|
||||
break;
|
||||
case 'verbose':
|
||||
flags.push('-vv'); // Print meta + input data
|
||||
break;
|
||||
case 'trace':
|
||||
flags.push('-vvv'); // Print meta + input + output
|
||||
break;
|
||||
}
|
||||
// Add debug flag if enabled
|
||||
if (logConfig?.debug) {
|
||||
flags.push('--debug');
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
*
|
||||
* This file maintains the `cubes` namespace for existing code.
|
||||
* New code should import directly from './cubes/index.js'
|
||||
*
|
||||
* @deprecated Import from './cubes/index.js' instead
|
||||
*/
|
||||
import * as cubesModule from './cubes/index.js';
|
||||
export declare const cubes: {
|
||||
Cube: typeof cubesModule.Cube;
|
||||
Manifest: typeof cubesModule.Manifest;
|
||||
createManifest: typeof cubesModule.createManifest;
|
||||
manifest: typeof cubesModule.createManifest;
|
||||
loadCubes: typeof cubesModule.loadCubes;
|
||||
getCube: typeof cubesModule.getCube;
|
||||
BuildContext: typeof cubesModule.BuildContext;
|
||||
uniqid: typeof cubesModule.uniqid;
|
||||
load: typeof cubesModule.loadCubes;
|
||||
findCubeDirectories: typeof cubesModule.findCubeDirectories;
|
||||
};
|
||||
export type { Hook, HookContext, Cube, Manifest, LoadResult, CubeVariables, } from './cubes/index.js';
|
||||
Vendored
-16
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
*
|
||||
* This file maintains the `cubes` namespace for existing code.
|
||||
* New code should import directly from './cubes/index.js'
|
||||
*
|
||||
* @deprecated Import from './cubes/index.js' instead
|
||||
*/
|
||||
import * as cubesModule from './cubes/index.js';
|
||||
export const cubes = {
|
||||
// Runtime exports
|
||||
...cubesModule,
|
||||
// Aliases for backwards compatibility
|
||||
load: cubesModule.loadCubes,
|
||||
findCubeDirectories: cubesModule.findCubeDirectories,
|
||||
};
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Pyinfra command execution
|
||||
* @module nopy.executor
|
||||
*/
|
||||
import type { DependencySpec } from './cubes/types.js';
|
||||
/**
|
||||
* A deployment command ready for execution
|
||||
*/
|
||||
export interface DeployCall {
|
||||
/** Cube being deployed */
|
||||
cube: string;
|
||||
/** Target host */
|
||||
host: string;
|
||||
/** Working directory for execution */
|
||||
cwd: string;
|
||||
/** Full command array */
|
||||
command: string[];
|
||||
/** Environment variables for the cube */
|
||||
env: Record<string, unknown>;
|
||||
/** Cube dependencies */
|
||||
dependencies: DependencySpec[];
|
||||
}
|
||||
/**
|
||||
* Result of executing a deployment command
|
||||
*/
|
||||
export interface ExecutionResult {
|
||||
/** Cube that was deployed */
|
||||
cube: string;
|
||||
/** Target host */
|
||||
host: string;
|
||||
/** Whether execution succeeded */
|
||||
success: boolean;
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number;
|
||||
/** Standard output (if captured) */
|
||||
stdout?: string;
|
||||
/** Standard error (if captured) */
|
||||
stderr?: string;
|
||||
/** Error if execution failed */
|
||||
error?: Error;
|
||||
}
|
||||
/**
|
||||
* Options for deployment execution
|
||||
*/
|
||||
export interface ExecutionOptions {
|
||||
/** Continue executing remaining cubes after failure */
|
||||
continueOnError?: boolean;
|
||||
/** Show what would be executed without running */
|
||||
dryRun?: boolean;
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
|
||||
/** Callback when execution starts */
|
||||
onStart?: (cube: string, host: string) => void;
|
||||
}
|
||||
/**
|
||||
* Outputs the execution plan without running (dry run)
|
||||
*
|
||||
* @param calls - Array of deployment calls
|
||||
* @param asJson - Output as JSON instead of text
|
||||
*/
|
||||
export declare function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void;
|
||||
/**
|
||||
* Executes an array of deployment calls
|
||||
*
|
||||
* @param calls - Array of deployment calls to execute
|
||||
* @param options - Execution options
|
||||
* @returns Array of execution results
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = await executeDeployCalls(calls, {
|
||||
* continueOnError: false,
|
||||
* onProgress: (result, completed, total) => {
|
||||
* console.log(`${completed}/${total} complete`);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function executeDeployCalls(calls: DeployCall[], options?: ExecutionOptions): Promise<ExecutionResult[]>;
|
||||
/**
|
||||
* Generates a summary of execution results
|
||||
*
|
||||
* @param results - Array of execution results
|
||||
* @returns Summary object
|
||||
*/
|
||||
export declare function summarizeResults(results: ExecutionResult[]): {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
failures: ExecutionResult[];
|
||||
};
|
||||
Vendored
-138
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Pyinfra command execution
|
||||
* @module nopy.executor
|
||||
*/
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { execa } from 'execa';
|
||||
const log = getLogger(['nopy', 'executor']);
|
||||
/**
|
||||
* Executes a single deployment call
|
||||
*
|
||||
* @param call - The deployment call to execute
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function executeCall(call) {
|
||||
const startTime = Date.now();
|
||||
const commandStr = call.command.join(' ');
|
||||
try {
|
||||
log.info(`Executing: ${call.cube} -> ${call.host}`);
|
||||
log.debug(`Command: ${commandStr}`);
|
||||
// Inherit stdio for live output
|
||||
await execa({ shell: true })(commandStr, {
|
||||
cwd: call.cwd,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return {
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
log.error(`Failed: ${call.cube} -> ${call.host}`, { error: err.message });
|
||||
return {
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
success: false,
|
||||
duration: Date.now() - startTime,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Outputs the execution plan without running (dry run)
|
||||
*
|
||||
* @param calls - Array of deployment calls
|
||||
* @param asJson - Output as JSON instead of text
|
||||
*/
|
||||
export function outputExecutionPlan(calls, asJson) {
|
||||
if (asJson) {
|
||||
const plan = calls.map((call) => ({
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
command: call.command.join(' '),
|
||||
variables: call.env,
|
||||
}));
|
||||
console.log(JSON.stringify({ plan }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log('\n=== Execution Plan (Dry Run) ===\n');
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
console.log(`Step ${i + 1}: ${call.cube} -> ${call.host}`);
|
||||
console.log(` Command: ${call.command.join(' ')}`);
|
||||
const envKeys = Object.keys(call.env);
|
||||
if (envKeys.length > 0) {
|
||||
console.log(' Variables:');
|
||||
for (const [key, value] of Object.entries(call.env)) {
|
||||
// Mask sensitive values
|
||||
const displayValue = key.toLowerCase().includes('password') ? '********' : String(value);
|
||||
console.log(` ${key}=${displayValue}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
console.log(`Total: ${calls.length} command(s)\n`);
|
||||
console.log('Run without --dry-run to execute.\n');
|
||||
}
|
||||
/**
|
||||
* Executes an array of deployment calls
|
||||
*
|
||||
* @param calls - Array of deployment calls to execute
|
||||
* @param options - Execution options
|
||||
* @returns Array of execution results
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = await executeDeployCalls(calls, {
|
||||
* continueOnError: false,
|
||||
* onProgress: (result, completed, total) => {
|
||||
* console.log(`${completed}/${total} complete`);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function executeDeployCalls(calls, options = {}) {
|
||||
if (calls.length === 0) {
|
||||
log.info('No deployment calls to execute');
|
||||
return [];
|
||||
}
|
||||
if (options.dryRun) {
|
||||
outputExecutionPlan(calls);
|
||||
return [];
|
||||
}
|
||||
log.info(`Executing ${calls.length} deployment call(s)`);
|
||||
const results = [];
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
options.onStart?.(call.cube, call.host);
|
||||
const result = await executeCall(call);
|
||||
results.push(result);
|
||||
options.onProgress?.(result, i + 1, calls.length);
|
||||
if (!result.success && !options.continueOnError) {
|
||||
log.warn(`Stopping execution due to failure in ${call.cube}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/**
|
||||
* Generates a summary of execution results
|
||||
*
|
||||
* @param results - Array of execution results
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function summarizeResults(results) {
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
return {
|
||||
total: results.length,
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
totalDuration,
|
||||
failures: failed,
|
||||
};
|
||||
}
|
||||
Vendored
-90
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Session history management
|
||||
* @module nopy.history
|
||||
*/
|
||||
import type { NopySession } from './nopy.session.js';
|
||||
/** Default number of sessions to keep in history */
|
||||
export declare const DEFAULT_HISTORY_SIZE = 10;
|
||||
/** History file name */
|
||||
export declare const HISTORY_FILE = ".nopy.history.json";
|
||||
/**
|
||||
* A session entry in history
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
/** Unique identifier (timestamp-based) */
|
||||
id: string;
|
||||
/** Human-readable name (timestamp + cube names) */
|
||||
name: string;
|
||||
/** ISO timestamp when session was executed */
|
||||
timestamp: string;
|
||||
/** The full session data */
|
||||
session: NopySession;
|
||||
}
|
||||
/**
|
||||
* History file structure
|
||||
*/
|
||||
export interface SessionHistory {
|
||||
/** Array of session entries, newest first */
|
||||
entries: HistoryEntry[];
|
||||
}
|
||||
/**
|
||||
* Gets the path to the history file
|
||||
*/
|
||||
export declare function getHistoryPath(): string;
|
||||
/**
|
||||
* Loads the session history from disk
|
||||
*
|
||||
* @returns The session history or empty history if file doesn't exist
|
||||
*/
|
||||
export declare function loadHistory(): SessionHistory;
|
||||
/**
|
||||
* Saves the session history to disk
|
||||
*
|
||||
* @param history - The history to save
|
||||
*/
|
||||
export declare function saveHistory(history: SessionHistory): void;
|
||||
/**
|
||||
* Adds a session to the history
|
||||
*
|
||||
* @param session - The session to add
|
||||
* @param maxEntries - Maximum number of entries to keep
|
||||
* @returns The created history entry
|
||||
*/
|
||||
export declare function addToHistory(session: NopySession, maxEntries?: number): HistoryEntry;
|
||||
/**
|
||||
* Gets the most recent session from history
|
||||
*
|
||||
* @returns The last session or undefined if history is empty
|
||||
*/
|
||||
export declare function getLastSession(): HistoryEntry | undefined;
|
||||
/**
|
||||
* Gets a session by ID
|
||||
*
|
||||
* @param id - The session ID
|
||||
* @returns The session entry or undefined
|
||||
*/
|
||||
export declare function getSessionById(id: string): HistoryEntry | undefined;
|
||||
/**
|
||||
* Lists all sessions in history
|
||||
*
|
||||
* @returns Array of history entries, newest first
|
||||
*/
|
||||
export declare function listHistory(): HistoryEntry[];
|
||||
/**
|
||||
* Clears all session history
|
||||
*/
|
||||
export declare function clearHistory(): void;
|
||||
/**
|
||||
* Removes a specific session from history
|
||||
*
|
||||
* @param id - The session ID to remove
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export declare function removeFromHistory(id: string): boolean;
|
||||
/**
|
||||
* Formats history entries for display
|
||||
*
|
||||
* @param entries - History entries to format
|
||||
* @returns Formatted string for console output
|
||||
*/
|
||||
export declare function formatHistoryList(entries: HistoryEntry[]): string;
|
||||
Vendored
-170
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Session history management
|
||||
* @module nopy.history
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/** Default number of sessions to keep in history */
|
||||
export const DEFAULT_HISTORY_SIZE = 10;
|
||||
/** History file name */
|
||||
export const HISTORY_FILE = '.nopy.history.json';
|
||||
/**
|
||||
* Gets the path to the history file
|
||||
*/
|
||||
export function getHistoryPath() {
|
||||
return path.resolve(process.cwd(), HISTORY_FILE);
|
||||
}
|
||||
/**
|
||||
* Loads the session history from disk
|
||||
*
|
||||
* @returns The session history or empty history if file doesn't exist
|
||||
*/
|
||||
export function loadHistory() {
|
||||
const historyPath = getHistoryPath();
|
||||
if (!fs.existsSync(historyPath)) {
|
||||
return { entries: [] };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(historyPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
catch {
|
||||
return { entries: [] };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Saves the session history to disk
|
||||
*
|
||||
* @param history - The history to save
|
||||
*/
|
||||
export function saveHistory(history) {
|
||||
const historyPath = getHistoryPath();
|
||||
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
|
||||
}
|
||||
/**
|
||||
* Generates a history entry name from session data
|
||||
*
|
||||
* Format: "YYYY-MM-DD HH:mm - cube1, cube2, ..."
|
||||
*
|
||||
* @param session - The session to name
|
||||
* @param timestamp - ISO timestamp
|
||||
* @returns Human-readable name
|
||||
*/
|
||||
function generateEntryName(session, timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const dateStr = date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const cubeNames = session.cubes.map((c) => c.key).join(', ');
|
||||
const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames;
|
||||
const hosts = session.hosts?.join(', ') || 'no host';
|
||||
const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts;
|
||||
return `${dateStr} - ${truncatedCubes} → ${truncatedHosts}`;
|
||||
}
|
||||
/**
|
||||
* Generates a unique ID for a history entry
|
||||
*/
|
||||
function generateEntryId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
||||
}
|
||||
/**
|
||||
* Adds a session to the history
|
||||
*
|
||||
* @param session - The session to add
|
||||
* @param maxEntries - Maximum number of entries to keep
|
||||
* @returns The created history entry
|
||||
*/
|
||||
export function addToHistory(session, maxEntries = DEFAULT_HISTORY_SIZE) {
|
||||
const history = loadHistory();
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = {
|
||||
id: generateEntryId(),
|
||||
name: generateEntryName(session, timestamp),
|
||||
timestamp,
|
||||
session,
|
||||
};
|
||||
// Add to beginning (newest first)
|
||||
history.entries.unshift(entry);
|
||||
// Trim to max size
|
||||
if (history.entries.length > maxEntries) {
|
||||
history.entries = history.entries.slice(0, maxEntries);
|
||||
}
|
||||
saveHistory(history);
|
||||
return entry;
|
||||
}
|
||||
/**
|
||||
* Gets the most recent session from history
|
||||
*
|
||||
* @returns The last session or undefined if history is empty
|
||||
*/
|
||||
export function getLastSession() {
|
||||
const history = loadHistory();
|
||||
return history.entries[0];
|
||||
}
|
||||
/**
|
||||
* Gets a session by ID
|
||||
*
|
||||
* @param id - The session ID
|
||||
* @returns The session entry or undefined
|
||||
*/
|
||||
export function getSessionById(id) {
|
||||
const history = loadHistory();
|
||||
return history.entries.find((e) => e.id === id);
|
||||
}
|
||||
/**
|
||||
* Lists all sessions in history
|
||||
*
|
||||
* @returns Array of history entries, newest first
|
||||
*/
|
||||
export function listHistory() {
|
||||
const history = loadHistory();
|
||||
return history.entries;
|
||||
}
|
||||
/**
|
||||
* Clears all session history
|
||||
*/
|
||||
export function clearHistory() {
|
||||
saveHistory({ entries: [] });
|
||||
}
|
||||
/**
|
||||
* Removes a specific session from history
|
||||
*
|
||||
* @param id - The session ID to remove
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export function removeFromHistory(id) {
|
||||
const history = loadHistory();
|
||||
const initialLength = history.entries.length;
|
||||
history.entries = history.entries.filter((e) => e.id !== id);
|
||||
if (history.entries.length < initialLength) {
|
||||
saveHistory(history);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Formats history entries for display
|
||||
*
|
||||
* @param entries - History entries to format
|
||||
* @returns Formatted string for console output
|
||||
*/
|
||||
export function formatHistoryList(entries) {
|
||||
if (entries.length === 0) {
|
||||
return 'No sessions in history.';
|
||||
}
|
||||
const lines = ['', 'Session History:', ''];
|
||||
entries.forEach((entry, index) => {
|
||||
const marker = index === 0 ? '→' : ' ';
|
||||
lines.push(` ${marker} [${index + 1}] ${entry.name}`);
|
||||
lines.push(` ID: ${entry.id}`);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push(`Total: ${entries.length} session(s)`);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
Vendored
-38
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Main entry point for nopy
|
||||
* @module nopy.main
|
||||
*/
|
||||
import { type ExecutionResult } from './nopy.executor.js';
|
||||
import { type NopySession } from './nopy.session.js';
|
||||
/**
|
||||
* Options for the nopy main function
|
||||
*/
|
||||
export interface NopyOptions {
|
||||
useDefaults?: boolean;
|
||||
useAuthKey?: boolean;
|
||||
saveSession?: string;
|
||||
loadSession?: string;
|
||||
replaySession?: NopySession;
|
||||
dryRun?: boolean;
|
||||
printOnly?: boolean;
|
||||
continueOnError?: boolean;
|
||||
jsonOutput?: boolean;
|
||||
saveToHistory?: boolean;
|
||||
}
|
||||
/**
|
||||
* Result of a nopy execution
|
||||
*/
|
||||
export interface NopyResult {
|
||||
success: boolean;
|
||||
results: ExecutionResult[];
|
||||
summary: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Main entry point for nopy deployments
|
||||
*/
|
||||
export declare function nopy(opts?: NopyOptions): Promise<NopyResult | undefined>;
|
||||
Vendored
-163
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Main entry point for nopy
|
||||
* @module nopy.main
|
||||
*/
|
||||
import { configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape';
|
||||
import { loadCubes } from './cubes/index.js';
|
||||
import { BuildContext } from './cubes/dependencies.js';
|
||||
import { Variables } from './nopy.common.js';
|
||||
import { getConfigPaths, loadConfig } from './nopy.config.js';
|
||||
import { executeDeployCalls, summarizeResults } from './nopy.executor.js';
|
||||
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
|
||||
import { saveSession } from './nopy.session.js';
|
||||
import { runWorkflow } from './nopy.workflow.js';
|
||||
/**
|
||||
* Configures the logtape logger for console output
|
||||
*/
|
||||
function configureLogtape() {
|
||||
configure({
|
||||
sinks: {
|
||||
console: (() => {
|
||||
const formatter = getAnsiColorFormatter();
|
||||
return (record) => {
|
||||
const formatted = formatter(record);
|
||||
if (typeof formatted === 'string') {
|
||||
const msg = formatted.replace(/\r?\n$/, '');
|
||||
const props = record.properties;
|
||||
console.log(msg, ...Object.values(props));
|
||||
}
|
||||
};
|
||||
})(),
|
||||
},
|
||||
loggers: [
|
||||
{
|
||||
category: ['logtape', 'meta'],
|
||||
level: 'error',
|
||||
sinks: ['console'],
|
||||
},
|
||||
{
|
||||
category: 'nopy',
|
||||
level: 'debug',
|
||||
sinks: ['console'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// Initialize logging
|
||||
configureLogtape();
|
||||
/**
|
||||
* Prints the active configuration summary
|
||||
*/
|
||||
function printActiveConfig(config, opts) {
|
||||
const configPaths = getConfigPaths();
|
||||
const cwd = process.cwd();
|
||||
const lines = [''];
|
||||
lines.push(' Configuration');
|
||||
lines.push(' ─────────────');
|
||||
const relativePaths = configPaths.map((p) => {
|
||||
if (p.startsWith(cwd))
|
||||
return `.${p.slice(cwd.length)}`;
|
||||
if (p.startsWith(process.env.HOME || ''))
|
||||
return `~${p.slice((process.env.HOME || '').length)}`;
|
||||
return p;
|
||||
});
|
||||
lines.push(` Config: ${relativePaths.join(' → ')}`);
|
||||
if (config.hosts.length > 0)
|
||||
lines.push(` Hosts: ${config.hosts.join(', ')}`);
|
||||
if (config.cubeDirs.length > 0)
|
||||
lines.push(` Cube dirs: ${config.cubeDirs.join(', ')}`);
|
||||
if (opts.continueOnError)
|
||||
lines.push(' Execution: continue-on-error');
|
||||
const envEntries = Object.entries(config.env);
|
||||
if (envEntries.length > 0) {
|
||||
lines.push(' Env vars:');
|
||||
for (const [key, value] of envEntries) {
|
||||
const isEmpty = value === null || value === undefined || value === '';
|
||||
lines.push(` ${key}: ${isEmpty ? '<EMPTY>' : '<VALUE>'}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
/**
|
||||
* Main entry point for nopy deployments
|
||||
*/
|
||||
export async function nopy(opts = {}) {
|
||||
const { useDefaults = false, useAuthKey, saveSession: saveSessionPath, loadSession: loadSessionPath, replaySession, dryRun = false, printOnly = false, continueOnError = false, jsonOutput = false, saveToHistory = true, } = opts;
|
||||
const log = getLogger(['nopy']);
|
||||
const config = loadConfig();
|
||||
if (!jsonOutput && !replaySession && !loadSessionPath) {
|
||||
printActiveConfig(config, { continueOnError });
|
||||
}
|
||||
const { cubes, errors } = await loadCubes();
|
||||
const variables = new Variables(config.env);
|
||||
if (errors.length > 0) {
|
||||
log.error('Errors found during cube loading:');
|
||||
errors.forEach((error) => log.error(error));
|
||||
if (jsonOutput)
|
||||
console.log(JSON.stringify({ success: false, errors }, null, 2));
|
||||
return undefined;
|
||||
}
|
||||
const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession);
|
||||
// Step 3: Build deployment calls using BuildContext
|
||||
const context = new BuildContext(cubes, variables, workflow.session, config, {
|
||||
method: workflow.authMethod,
|
||||
username: workflow.username,
|
||||
password: workflow.password,
|
||||
}, {
|
||||
useDefaults,
|
||||
isSessionReplay: workflow.isReplay,
|
||||
});
|
||||
for (const host of workflow.session.hosts) {
|
||||
for (const cubeId of workflow.selectedCubes) {
|
||||
await context.resolveCube(cubeId, host);
|
||||
}
|
||||
}
|
||||
const sessionForSaving = {
|
||||
...workflow.session,
|
||||
cubes: context.cubeSessions,
|
||||
env: variables.get('global'),
|
||||
};
|
||||
if (saveSessionPath && !workflow.isReplay) {
|
||||
saveSession(sessionForSaving, saveSessionPath);
|
||||
}
|
||||
if (saveToHistory && !dryRun && !workflow.isReplay && context.deployCalls.length > 0) {
|
||||
const historySize = config.history?.maxSessions ?? DEFAULT_HISTORY_SIZE;
|
||||
if (config.history?.autoSave !== false) {
|
||||
addToHistory(sessionForSaving, historySize);
|
||||
}
|
||||
}
|
||||
if (printOnly) {
|
||||
console.log('\n Deploy Commands\n ───────────────\n');
|
||||
for (const call of context.deployCalls) {
|
||||
console.log(` # ${call.cube} -> ${call.host}`);
|
||||
console.log(` ${call.command.join(' ')}\n`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
results: [],
|
||||
summary: { total: context.deployCalls.length, successful: 0, failed: 0, totalDuration: 0 },
|
||||
};
|
||||
}
|
||||
const results = await executeDeployCalls(context.deployCalls, {
|
||||
dryRun,
|
||||
continueOnError,
|
||||
onProgress: (result, completed, total) => {
|
||||
if (!jsonOutput) {
|
||||
const status = result.success ? '✓' : '✗';
|
||||
log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const summary = summarizeResults(results);
|
||||
return {
|
||||
success: summary.failed === 0,
|
||||
results,
|
||||
summary: {
|
||||
total: summary.total,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
totalDuration: summary.totalDuration,
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
-21
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Interactive prompts for nopy CLI
|
||||
* @module nopy.prompts
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { Cube } from './cubes/index.js';
|
||||
import type { Variables } from './nopy.common.js';
|
||||
/**
|
||||
* Prompts the user to select cubes to execute with filtering support
|
||||
*/
|
||||
export declare function CubeSelection(cubes: Record<string, Cube>): Promise<{
|
||||
selectedCubes: string[];
|
||||
}>;
|
||||
export declare function AuthSelection(useAuthKey?: boolean): Promise<{
|
||||
authMethod: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>;
|
||||
export declare function PasswordSelection(username: string): Promise<string>;
|
||||
export declare function HostSelection(hosts: string[]): Promise<string>;
|
||||
export declare function VariableAssignment<S extends z.AnyZodObject>(cube: Cube<S>, variables: Variables): Promise<void>;
|
||||
Vendored
-174
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Interactive prompts for nopy CLI
|
||||
* @module nopy.prompts
|
||||
*/
|
||||
// @ts-ignore - no types available
|
||||
import Enquirer from 'enquirer';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
// @ts-ignore - no types available
|
||||
import CheckboxPlus from 'inquirer-checkbox-plus-prompt';
|
||||
import { z } from 'zod';
|
||||
// Register the checkbox-plus prompt type for filterable multi-select
|
||||
inquirer.registerPrompt('checkbox-plus', CheckboxPlus);
|
||||
/**
|
||||
* Prompts the user to select cubes to execute with filtering support
|
||||
*/
|
||||
export async function CubeSelection(cubes) {
|
||||
const cubeChoices = Object.values(cubes)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((cube) => ({
|
||||
name: `${cube.id} - ${cube.name}`,
|
||||
value: cube.id,
|
||||
short: cube.id,
|
||||
}));
|
||||
// Clear terminal and move cursor to top
|
||||
process.stdout.write('\x1B[2J\x1B[0f');
|
||||
const terminalHeight = process.stdout.rows || 24;
|
||||
const pageSize = Math.max(10, terminalHeight - 5);
|
||||
console.log('\n Cube Selection\n');
|
||||
console.log(' Type to filter • Space to select • Enter to confirm\n');
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox-plus',
|
||||
name: 'selectedCubes',
|
||||
message: 'Select cubes:',
|
||||
pageSize,
|
||||
highlight: true,
|
||||
searchable: true,
|
||||
source: (_answersSoFar, input) => {
|
||||
const searchTerm = input || '';
|
||||
if (!searchTerm)
|
||||
return Promise.resolve(cubeChoices);
|
||||
const results = fuzzy.filter(searchTerm, cubeChoices, {
|
||||
extract: (choice) => choice.name,
|
||||
});
|
||||
return Promise.resolve(results.map((r) => r.original));
|
||||
},
|
||||
},
|
||||
]);
|
||||
return { selectedCubes: answers.selectedCubes };
|
||||
}
|
||||
export async function AuthSelection(useAuthKey) {
|
||||
if (useAuthKey)
|
||||
return { authMethod: 'ssh-key' };
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'authMethod',
|
||||
message: 'Select authentication method:',
|
||||
choices: ['ssh-key', 'password'],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Enter username:',
|
||||
when: (answers) => answers.authMethod !== 'ssh-key',
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter password:',
|
||||
when: (answers) => answers.authMethod !== 'ssh-key',
|
||||
},
|
||||
]);
|
||||
return answers;
|
||||
}
|
||||
export async function PasswordSelection(username) {
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: `Enter password for ${username}:`,
|
||||
},
|
||||
]);
|
||||
return password;
|
||||
}
|
||||
export async function HostSelection(hosts) {
|
||||
const selectedHost = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'host',
|
||||
message: 'Select host from inventory',
|
||||
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customHost',
|
||||
message: 'Specify custom host address:',
|
||||
when: (answers) => answers.host === 'custom',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'vagrantVM',
|
||||
message: 'Specify vagrant machine:',
|
||||
default: 'default',
|
||||
when: (answers) => answers.host === 'vagrant',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'dockerContainer',
|
||||
message: 'Specify docker container name:',
|
||||
when: (answers) => answers.host === 'runtime:docker',
|
||||
},
|
||||
]);
|
||||
if (selectedHost.host === 'vagrant')
|
||||
return `@vagrant/${selectedHost.vagrantVM}`;
|
||||
if (selectedHost.host === 'runtime:docker')
|
||||
return `@docker/${selectedHost.dockerContainer}`;
|
||||
return selectedHost.customHost ?? selectedHost.host;
|
||||
}
|
||||
function coerceValue(value, zodType) {
|
||||
if (typeof value !== 'string')
|
||||
return value;
|
||||
if (zodType instanceof z.ZodDefault)
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
if (zodType instanceof z.ZodOptional)
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
if (zodType instanceof z.ZodNullable) {
|
||||
if (value === 'null' || value === '')
|
||||
return null;
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
}
|
||||
if (zodType instanceof z.ZodBoolean)
|
||||
return value === 'true' || value === 'yes' || value === '1';
|
||||
if (zodType instanceof z.ZodNumber) {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? value : num;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export async function VariableAssignment(cube, variables) {
|
||||
const schema = cube.manifest.schema.shape;
|
||||
const defaults = cube.getDefaults();
|
||||
const variablesToConfigure = {};
|
||||
for (const [key, defaultValue] of Object.entries(defaults)) {
|
||||
if (variables.get(cube.id, 'params')[key] === undefined) {
|
||||
variablesToConfigure[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
if (Object.keys(variablesToConfigure).length === 0)
|
||||
return;
|
||||
const choices = Object.entries(variablesToConfigure).map(([key, value]) => {
|
||||
const zodType = schema[key];
|
||||
const description = zodType?.description || key;
|
||||
return { name: key, message: description, initial: String(value ?? '') };
|
||||
});
|
||||
const form = new Enquirer.Form({
|
||||
name: 'variables',
|
||||
message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`,
|
||||
choices,
|
||||
});
|
||||
try {
|
||||
const result = await form.run();
|
||||
const coercedResult = {};
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
const zodType = schema[key];
|
||||
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
|
||||
}
|
||||
variables.assign(cube.id, 'prompts', coercedResult);
|
||||
}
|
||||
catch {
|
||||
// User cancelled
|
||||
}
|
||||
}
|
||||
Vendored
-112
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Session management for saving and replaying deployments
|
||||
* @module nopy.session
|
||||
*/
|
||||
import type { TVariables } from './nopy.common.js';
|
||||
/**
|
||||
* Primitive value types that can be stored in session variables
|
||||
*/
|
||||
export type SessionValue = string | number | boolean | null | undefined;
|
||||
/**
|
||||
* Record of session variables
|
||||
*/
|
||||
export type SessionVariables = Record<string, unknown>;
|
||||
/**
|
||||
* Configuration for a single cube within a session
|
||||
*/
|
||||
export interface CubeSession {
|
||||
/** Cube identifier */
|
||||
key: string;
|
||||
/** Cube-specific variables */
|
||||
variables: TVariables;
|
||||
}
|
||||
/**
|
||||
* Authentication configuration for a session
|
||||
*/
|
||||
export interface AuthSession {
|
||||
/** Authentication method */
|
||||
method: 'ssh-key' | 'password' | 'ssh';
|
||||
/** Username for authentication (password auth only) */
|
||||
username?: string;
|
||||
}
|
||||
/**
|
||||
* Complete session configuration
|
||||
*/
|
||||
export interface NopySession {
|
||||
/** Optional session name */
|
||||
name?: string;
|
||||
/** Array of cube configurations */
|
||||
cubes: CubeSession[];
|
||||
/** Target hosts */
|
||||
hosts?: string[];
|
||||
/** Authentication configuration */
|
||||
auth: AuthSession;
|
||||
/** Global environment variables */
|
||||
env?: TVariables;
|
||||
}
|
||||
/**
|
||||
* Saves a session to a JSON file
|
||||
*
|
||||
* @param session - The session to save
|
||||
* @param filePath - Path to save the session file
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* saveSession(session, './my-deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export declare function saveSession(session: NopySession, filePath: string): void;
|
||||
/**
|
||||
* Loads a session from a JSON or MJS file
|
||||
*
|
||||
* @param filePath - Path to the session file (.json or .mjs)
|
||||
* @returns The loaded session
|
||||
* @throws Error if file not found or invalid format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = await loadSession('./deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export declare function loadSession(filePath: string): Promise<NopySession>;
|
||||
/**
|
||||
* Lists all session files in a directory
|
||||
*
|
||||
* @param dirPath - Directory to search for session files
|
||||
* @returns Array of session file paths
|
||||
*/
|
||||
export declare function listSessions(dirPath?: string): string[];
|
||||
/**
|
||||
* Creates a session object from runtime data
|
||||
*
|
||||
* @param params - Session parameters
|
||||
* @returns A NopySession object
|
||||
*/
|
||||
export declare function createSession(params: {
|
||||
name?: string;
|
||||
cubes: CubeSession[];
|
||||
hosts: string[];
|
||||
auth: AuthSession;
|
||||
env?: TVariables;
|
||||
}): NopySession;
|
||||
/**
|
||||
* Filters out internal variables from cube variables
|
||||
*
|
||||
* Internal variables are those used by the prompts system
|
||||
* and should not be saved in session files.
|
||||
*
|
||||
* @param variables - Variables object
|
||||
* @returns Filtered variables without internal keys
|
||||
*/
|
||||
export declare function filterInternalVariables(variables: Record<string, unknown>): Record<string, unknown>;
|
||||
/**
|
||||
* Separates environment variables from cube-specific variables
|
||||
*
|
||||
* @param allVariables - All variables including env and cube-specific
|
||||
* @param envVariables - Known environment variables from config
|
||||
* @returns Object with separate env and cube variables
|
||||
*/
|
||||
export declare function separateEnvAndCubeVariables(allVariables: Record<string, unknown>, envVariables: Record<string, unknown>): {
|
||||
env: Record<string, unknown>;
|
||||
cubeVars: Record<string, unknown>;
|
||||
};
|
||||
Vendored
-166
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Session management for saving and replaying deployments
|
||||
* @module nopy.session
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/**
|
||||
* Saves a session to a JSON file
|
||||
*
|
||||
* @param session - The session to save
|
||||
* @param filePath - Path to save the session file
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* saveSession(session, './my-deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export function saveSession(session, filePath) {
|
||||
const sessionToSave = {
|
||||
...session,
|
||||
};
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(sessionToSave, null, 2), 'utf-8');
|
||||
}
|
||||
/**
|
||||
* Loads a session from an MJS file
|
||||
*
|
||||
* @param filePath - Path to the MJS session file
|
||||
* @returns The loaded session
|
||||
*/
|
||||
async function loadSessionFromMJS(filePath) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const fileUrl = `file://${absolutePath}`;
|
||||
try {
|
||||
const module = (await import(fileUrl));
|
||||
const session = module.default;
|
||||
if (!session) {
|
||||
throw new Error('MJS file must export a default object');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to load MJS session: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Loads a session from a JSON file
|
||||
*
|
||||
* @param filePath - Path to the JSON session file
|
||||
* @returns The loaded session
|
||||
*/
|
||||
function loadSessionFromJSON(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
/**
|
||||
* Loads a session from a JSON or MJS file
|
||||
*
|
||||
* @param filePath - Path to the session file (.json or .mjs)
|
||||
* @returns The loaded session
|
||||
* @throws Error if file not found or invalid format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = await loadSession('./deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export async function loadSession(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Session file not found: ${filePath}`);
|
||||
}
|
||||
const ext = path.extname(filePath);
|
||||
let session;
|
||||
if (ext === '.mjs') {
|
||||
session = await loadSessionFromMJS(filePath);
|
||||
}
|
||||
else if (ext === '.json') {
|
||||
session = loadSessionFromJSON(filePath);
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unsupported session file format: ${ext}. Use .json or .mjs`);
|
||||
}
|
||||
// Validate required fields
|
||||
if (!session.cubes || !Array.isArray(session.cubes)) {
|
||||
throw new Error('Invalid session format: missing or invalid "cubes" field');
|
||||
}
|
||||
if (session.hosts && !Array.isArray(session.hosts)) {
|
||||
throw new Error('Invalid session format: invalid "hosts" field');
|
||||
}
|
||||
if (!session.auth) {
|
||||
throw new Error('Invalid session format: missing "auth" field');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
/**
|
||||
* Lists all session files in a directory
|
||||
*
|
||||
* @param dirPath - Directory to search for session files
|
||||
* @returns Array of session file paths
|
||||
*/
|
||||
export function listSessions(dirPath = process.cwd()) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
const files = fs.readdirSync(dirPath);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
|
||||
.map((file) => path.join(dirPath, file));
|
||||
}
|
||||
/**
|
||||
* Creates a session object from runtime data
|
||||
*
|
||||
* @param params - Session parameters
|
||||
* @returns A NopySession object
|
||||
*/
|
||||
export function createSession(params) {
|
||||
return {
|
||||
name: params.name,
|
||||
cubes: params.cubes,
|
||||
hosts: params.hosts,
|
||||
auth: params.auth,
|
||||
env: params.env,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Filters out internal variables from cube variables
|
||||
*
|
||||
* Internal variables are those used by the prompts system
|
||||
* and should not be saved in session files.
|
||||
*
|
||||
* @param variables - Variables object
|
||||
* @returns Filtered variables without internal keys
|
||||
*/
|
||||
export function filterInternalVariables(variables) {
|
||||
const internalKeys = ['customize'];
|
||||
const filtered = {};
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (!internalKeys.includes(key)) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
/**
|
||||
* Separates environment variables from cube-specific variables
|
||||
*
|
||||
* @param allVariables - All variables including env and cube-specific
|
||||
* @param envVariables - Known environment variables from config
|
||||
* @returns Object with separate env and cube variables
|
||||
*/
|
||||
export function separateEnvAndCubeVariables(allVariables, envVariables) {
|
||||
const env = {};
|
||||
const cubeVars = {};
|
||||
for (const [key, value] of Object.entries(allVariables)) {
|
||||
if (key in envVariables) {
|
||||
env[key] = value;
|
||||
}
|
||||
else {
|
||||
cubeVars[key] = value;
|
||||
}
|
||||
}
|
||||
return { env, cubeVars };
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Workflow logic for interactive and replay modes
|
||||
* @module nopy.workflow
|
||||
*/
|
||||
import type { Cube } from './cubes/index.js';
|
||||
import type { NopyConfig } from './nopy.config.js';
|
||||
import { type NopySession } from './nopy.session.js';
|
||||
/**
|
||||
* Options for workflow execution
|
||||
*/
|
||||
export interface WorkflowOptions {
|
||||
/** Use defaults without prompting */
|
||||
useDefaults?: boolean;
|
||||
/** Force SSH key authentication */
|
||||
useAuthKey?: boolean;
|
||||
}
|
||||
/**
|
||||
* Result of running a workflow
|
||||
*/
|
||||
export interface WorkflowResult {
|
||||
/** The session configuration */
|
||||
session: NopySession;
|
||||
/** Target cubes selected for execution */
|
||||
selectedCubes: string[];
|
||||
/** Authentication method used */
|
||||
authMethod: string;
|
||||
/** Username if applicable */
|
||||
username?: string;
|
||||
/** Password if applicable */
|
||||
password?: string;
|
||||
/** Whether this is a session replay */
|
||||
isReplay: boolean;
|
||||
}
|
||||
/**
|
||||
* Runs the interactive workflow for cube selection and configuration
|
||||
*/
|
||||
export declare function runInteractiveWorkflow(cubes: Record<string, Cube>, config: NopyConfig, options?: WorkflowOptions): Promise<WorkflowResult>;
|
||||
/**
|
||||
* Runs the replay workflow from a saved session file
|
||||
*/
|
||||
export declare function runReplayWorkflow(sessionPath: string, cubes: Record<string, Cube>, config: NopyConfig): Promise<WorkflowResult>;
|
||||
/**
|
||||
* Runs replay workflow from a session object (from history)
|
||||
*/
|
||||
export declare function runSessionReplayWorkflow(session: NopySession, cubes: Record<string, Cube>, config: NopyConfig): Promise<WorkflowResult>;
|
||||
/**
|
||||
* Determines the appropriate workflow based on options
|
||||
*/
|
||||
export declare function runWorkflow(sessionPath: string | undefined, cubes: Record<string, Cube>, config: NopyConfig, options?: WorkflowOptions, replaySession?: NopySession): Promise<WorkflowResult>;
|
||||
Vendored
-149
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Workflow logic for interactive and replay modes
|
||||
* @module nopy.workflow
|
||||
*/
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js';
|
||||
import { createSession, loadSession } from './nopy.session.js';
|
||||
const log = getLogger(['nopy', 'workflow']);
|
||||
/**
|
||||
* Runs the interactive workflow for cube selection and configuration
|
||||
*/
|
||||
export async function runInteractiveWorkflow(cubes, config, options = {}) {
|
||||
const { useAuthKey } = options;
|
||||
// Step 1: Select cubes
|
||||
const { selectedCubes } = await CubeSelection(cubes);
|
||||
log.info('Selected cubes', { selectedCubes });
|
||||
if (selectedCubes.length === 0) {
|
||||
log.warn('No cubes selected');
|
||||
}
|
||||
// Step 2: Select host
|
||||
const host = await HostSelection(config.hosts);
|
||||
// Step 3: Select authentication
|
||||
const isLocalHost = host.includes('@vagrant') || host.includes('@docker');
|
||||
const authResult = isLocalHost
|
||||
? { authMethod: 'ssh', username: undefined, password: undefined }
|
||||
: await AuthSelection(useAuthKey);
|
||||
// Create session
|
||||
const session = createSession({
|
||||
cubes: [], // Will be populated during build
|
||||
hosts: [host],
|
||||
auth: {
|
||||
method: authResult.authMethod,
|
||||
username: authResult.username,
|
||||
},
|
||||
env: config.env,
|
||||
});
|
||||
return {
|
||||
session,
|
||||
selectedCubes,
|
||||
authMethod: authResult.authMethod,
|
||||
username: authResult.username,
|
||||
password: authResult.password,
|
||||
isReplay: false,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Runs the replay workflow from a saved session file
|
||||
*/
|
||||
export async function runReplayWorkflow(sessionPath, cubes, config) {
|
||||
log.info('Loading session from', { path: sessionPath });
|
||||
const session = await loadSession(sessionPath);
|
||||
log.info('Session loaded', { name: session.name, cubeCount: session.cubes.length });
|
||||
// Validate cubes exist
|
||||
for (const cubeSession of session.cubes) {
|
||||
if (!cubes[cubeSession.key]) {
|
||||
log.warn(`Cube from session not found: ${cubeSession.key}`);
|
||||
}
|
||||
}
|
||||
// Handle missing hosts
|
||||
if (!session.hosts || session.hosts.length === 0) {
|
||||
log.info('No hosts in session, prompting for selection');
|
||||
const host = await HostSelection(config.hosts);
|
||||
session.hosts = [host];
|
||||
}
|
||||
// Extract auth details
|
||||
let authMethod = session.auth.method;
|
||||
let username = session.auth.username;
|
||||
let password;
|
||||
// Prompt for password if needed (passwords are never stored)
|
||||
if (authMethod === 'password') {
|
||||
if (username) {
|
||||
password = await PasswordSelection(username);
|
||||
}
|
||||
else {
|
||||
log.info('Password auth requires username, prompting');
|
||||
const authResult = await AuthSelection(false);
|
||||
authMethod = authResult.authMethod;
|
||||
username = authResult.username;
|
||||
password = authResult.password;
|
||||
}
|
||||
}
|
||||
// Target cubes are those in the session
|
||||
const selectedCubes = session.cubes.map((c) => c.key);
|
||||
return {
|
||||
session,
|
||||
selectedCubes,
|
||||
authMethod,
|
||||
username,
|
||||
password,
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Runs replay workflow from a session object (from history)
|
||||
*/
|
||||
export async function runSessionReplayWorkflow(session, cubes, config) {
|
||||
log.info('Replaying session from history', { cubeCount: session.cubes.length });
|
||||
// Validate cubes exist
|
||||
for (const cubeSession of session.cubes) {
|
||||
if (!cubes[cubeSession.key]) {
|
||||
log.warn(`Cube from session not found: ${cubeSession.key}`);
|
||||
}
|
||||
}
|
||||
// Handle missing hosts
|
||||
if (!session.hosts || session.hosts.length === 0) {
|
||||
log.info('No hosts in session, prompting for selection');
|
||||
const host = await HostSelection(config.hosts);
|
||||
session.hosts = [host];
|
||||
}
|
||||
// Extract auth details
|
||||
let authMethod = session.auth.method;
|
||||
let username = session.auth.username;
|
||||
let password;
|
||||
// Prompt for password if needed (passwords are never stored)
|
||||
if (authMethod === 'password') {
|
||||
if (username) {
|
||||
password = await PasswordSelection(username);
|
||||
}
|
||||
else {
|
||||
log.info('Password auth requires username, prompting');
|
||||
const authResult = await AuthSelection(false);
|
||||
authMethod = authResult.authMethod;
|
||||
username = authResult.username;
|
||||
password = authResult.password;
|
||||
}
|
||||
}
|
||||
// Target cubes are those in the session
|
||||
const selectedCubes = session.cubes.map((c) => c.key);
|
||||
return {
|
||||
session,
|
||||
selectedCubes,
|
||||
authMethod,
|
||||
username,
|
||||
password,
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Determines the appropriate workflow based on options
|
||||
*/
|
||||
export async function runWorkflow(sessionPath, cubes, config, options = {}, replaySession) {
|
||||
if (replaySession) {
|
||||
return runSessionReplayWorkflow(replaySession, cubes, config);
|
||||
}
|
||||
if (sessionPath) {
|
||||
return runReplayWorkflow(sessionPath, cubes, config);
|
||||
}
|
||||
return runInteractiveWorkflow(cubes, config, options);
|
||||
}
|
||||
+55
-26
@@ -1,44 +1,73 @@
|
||||
{
|
||||
"name": "@bitstack/nopy",
|
||||
"description": "A system to simplify pyinfra script management and execution.",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "A system to simplify pyinfra script management and execution.",
|
||||
"keywords": [
|
||||
"pyinfra",
|
||||
"deployment",
|
||||
"cli",
|
||||
"infrastructure"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "bitsquare",
|
||||
"bin": "./dist/nopy.cli.js",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
|
||||
"directory": "packages/nopy"
|
||||
},
|
||||
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/nopy",
|
||||
"bugs": {
|
||||
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"bin": {
|
||||
"nopy": "./dist/nopy.cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsgo && chmod +x dist/nopy.cli.js && npm link",
|
||||
"build:legacy": "tsc && chmod +x dist/nopy.cli.js && npm link",
|
||||
"prepublishOnly": "npm run build",
|
||||
"nopy": "node --loader ts-node/esm src/nopy.cli.ts",
|
||||
"debug": "node --inspect-brk --loader ts-node/esm src/nopy.cli.ts",
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"nopy": "tsx src/nopy.cli.ts",
|
||||
"debug": "tsx --inspect-brk src/nopy.cli.ts",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:integration": "vitest run --pool=forks",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"files": ["dist/"],
|
||||
"dependencies": {
|
||||
"@logtape/logtape": "^0.8.0",
|
||||
"commander": "^13.1.0",
|
||||
"@logtape/logtape": "^2.2.4",
|
||||
"commander": "^15.0.0",
|
||||
"enquirer": "^2.4.1",
|
||||
"execa": "9.5.2",
|
||||
"execa": "^10.0.0",
|
||||
"fuzzy": "^0.1.3",
|
||||
"inquirer": "8.2.4",
|
||||
"inquirer-checkbox-plus-prompt": "^1.0.1",
|
||||
"ts-node": ">=10.9.1",
|
||||
"typescript": ">=5.6.3",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^3.24.1",
|
||||
"zx": "^8.3.0"
|
||||
"inquirer": "^14.0.2",
|
||||
"zod": "^4.4.3",
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^8.2.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uniqid": "^5.3.4",
|
||||
"vitest": "^1.6.0"
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { Variables } from '../nopy.common.js';
|
||||
import type { NopyConfig } from '../nopy.config.js';
|
||||
import type { DeployCall } from '../nopy.executor.js';
|
||||
import { VariableAssignment } from '../nopy.prompts.js';
|
||||
import { type CubeSession, type NopySession } from '../nopy.session.js';
|
||||
import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js';
|
||||
import type { CubeSession, NopySession } from '../nopy.session.js';
|
||||
import type { Cube, CubeVariables, HookContext } from './types.js';
|
||||
|
||||
const log = getLogger(['nopy', 'resolution']);
|
||||
|
||||
@@ -40,7 +40,11 @@ export class BuildContext {
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
public async resolveCube(cubeId: string, host: string, overrides: CubeVariables = {}): Promise<void> {
|
||||
public async resolveCube(
|
||||
cubeId: string,
|
||||
host: string,
|
||||
overrides: CubeVariables = {}
|
||||
): Promise<void> {
|
||||
const cube = this.allCubes[cubeId];
|
||||
if (!cube) {
|
||||
throw new Error(`Cube not found: ${cubeId}`);
|
||||
@@ -56,7 +60,7 @@ export class BuildContext {
|
||||
|
||||
// 2. Variable collection
|
||||
if (this.options.isSessionReplay) {
|
||||
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
|
||||
const sessionCube = this.session.cubes.find((c) => c.key === cubeId);
|
||||
if (sessionCube) {
|
||||
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
|
||||
}
|
||||
@@ -128,7 +132,7 @@ export class BuildContext {
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
if (!this.cubeSessions.some(s => s.key === cubeId)) {
|
||||
if (!this.cubeSessions.some((s) => s.key === cubeId)) {
|
||||
this.cubeSessions.push({
|
||||
key: cubeId,
|
||||
variables: this.variables.get(cubeId, 'prompts'),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module cubes/factories
|
||||
*/
|
||||
|
||||
import { Manifest } from './types.js';
|
||||
import { type AnyObjectSchema, Manifest } from './types.js';
|
||||
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
@@ -11,7 +11,7 @@ import { Manifest } from './types.js';
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export function createManifest<Schema extends import('zod').z.AnyZodObject>(
|
||||
export function createManifest<Schema extends AnyObjectSchema>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
|
||||
@@ -6,37 +6,32 @@
|
||||
* @module cubes
|
||||
*/
|
||||
|
||||
// Dependencies
|
||||
export { BuildContext } from './dependencies.js';
|
||||
// Factory functions
|
||||
export {
|
||||
createManifest,
|
||||
manifest,
|
||||
} from './factories.js';
|
||||
// Loader
|
||||
export {
|
||||
findCubeDirectories,
|
||||
getCube,
|
||||
loadCubes,
|
||||
} from './loader.js';
|
||||
export type {
|
||||
AnyObjectSchema,
|
||||
CubeVariables,
|
||||
DependencySpec,
|
||||
Hook,
|
||||
HookContext,
|
||||
LoadResult,
|
||||
} from './types.js';
|
||||
// Types
|
||||
export {
|
||||
Cube,
|
||||
Manifest,
|
||||
} from './types.js';
|
||||
|
||||
export type {
|
||||
Hook,
|
||||
HookContext,
|
||||
LoadResult,
|
||||
CubeVariables,
|
||||
DependencySpec,
|
||||
} from './types.js';
|
||||
|
||||
// Factory functions
|
||||
export {
|
||||
createManifest,
|
||||
manifest,
|
||||
} from './factories.js';
|
||||
|
||||
// Loader
|
||||
export {
|
||||
loadCubes,
|
||||
findCubeDirectories,
|
||||
getCube,
|
||||
} from './loader.js';
|
||||
|
||||
// Dependencies
|
||||
export {
|
||||
BuildContext,
|
||||
} from './dependencies.js';
|
||||
|
||||
// Utilities
|
||||
export { uniqid } from './utils.js';
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Any object schema, whatever its shape.
|
||||
*
|
||||
* Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed.
|
||||
*/
|
||||
export type AnyObjectSchema = z.ZodObject<Record<string, z.ZodType<any>>>;
|
||||
|
||||
/**
|
||||
* Variables that can be passed to a cube
|
||||
*/
|
||||
@@ -25,7 +32,7 @@ export interface HookContext {
|
||||
/**
|
||||
* Hook function type for before/after cube execution
|
||||
*/
|
||||
export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
|
||||
export type Hook<Schema extends AnyObjectSchema = AnyObjectSchema> = (
|
||||
ctx: HookContext,
|
||||
variables: z.infer<Schema>
|
||||
) => void | Promise<void>;
|
||||
@@ -33,7 +40,7 @@ export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
|
||||
/**
|
||||
* User-defined specification for a cube
|
||||
*/
|
||||
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
export interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
|
||||
/** Unique identifier for the cube (used for dependency references) */
|
||||
id: string;
|
||||
/** Human-readable name of the cube */
|
||||
@@ -51,7 +58,7 @@ export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export function Manifest<Schema extends z.AnyZodObject>(
|
||||
export function Manifest<Schema extends AnyObjectSchema>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return {
|
||||
@@ -68,7 +75,7 @@ export namespace Manifest {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
export function create<Schema extends z.AnyZodObject>(
|
||||
export function create<Schema extends AnyObjectSchema>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
@@ -78,7 +85,7 @@ export namespace Manifest {
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
export class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
|
||||
constructor(
|
||||
public readonly manifest: Manifest<Schema>,
|
||||
public readonly dir: string,
|
||||
|
||||
+56
-64
@@ -6,81 +6,73 @@
|
||||
|
||||
// Cubes module
|
||||
export * from './cubes/index.js';
|
||||
|
||||
export type {
|
||||
ExecutionConfig,
|
||||
HistoryConfig,
|
||||
LogConfig,
|
||||
LogVerbosity,
|
||||
NopyConfig,
|
||||
NopyConfigFile,
|
||||
ResolutionConfig,
|
||||
ResolutionStrategy,
|
||||
} from './nopy.config.js';
|
||||
// Configuration
|
||||
export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js';
|
||||
// Backwards compatibility - cubes namespace
|
||||
export { cubes } from './nopy.cubes.js';
|
||||
|
||||
// Main entry point
|
||||
export { nopy } from './nopy.main.js';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
|
||||
export type {
|
||||
DeployCall,
|
||||
ExecutionOptions,
|
||||
ExecutionResult,
|
||||
} from './nopy.executor.js';
|
||||
// Executor
|
||||
export {
|
||||
executeDeployCalls,
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
export type {
|
||||
DeployCall,
|
||||
ExecutionResult,
|
||||
ExecutionOptions,
|
||||
} from './nopy.executor.js';
|
||||
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
// History management
|
||||
export {
|
||||
addToHistory,
|
||||
clearHistory,
|
||||
DEFAULT_HISTORY_SIZE,
|
||||
formatHistoryList,
|
||||
getHistoryPath,
|
||||
getLastSession,
|
||||
getSessionById,
|
||||
HISTORY_FILE,
|
||||
listHistory,
|
||||
loadHistory,
|
||||
removeFromHistory,
|
||||
saveHistory,
|
||||
} from './nopy.history.js';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
// Main entry point
|
||||
export { nopy } from './nopy.main.js';
|
||||
// Prompts
|
||||
export {
|
||||
AuthSelection,
|
||||
CubeSelection,
|
||||
HostSelection,
|
||||
PasswordSelection,
|
||||
VariableAssignment,
|
||||
} from './nopy.prompts.js';
|
||||
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
|
||||
// Session management
|
||||
export {
|
||||
createSession,
|
||||
filterInternalVariables,
|
||||
listSessions,
|
||||
loadSession,
|
||||
saveSession,
|
||||
separateEnvAndCubeVariables,
|
||||
} from './nopy.session.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
// Workflow
|
||||
export {
|
||||
runWorkflow,
|
||||
runInteractiveWorkflow,
|
||||
runReplayWorkflow,
|
||||
runSessionReplayWorkflow,
|
||||
runWorkflow,
|
||||
} from './nopy.workflow.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
|
||||
// Prompts
|
||||
export {
|
||||
CubeSelection,
|
||||
AuthSelection,
|
||||
HostSelection,
|
||||
VariableAssignment,
|
||||
PasswordSelection,
|
||||
} from './nopy.prompts.js';
|
||||
|
||||
// Session management
|
||||
export {
|
||||
loadSession,
|
||||
saveSession,
|
||||
createSession,
|
||||
listSessions,
|
||||
filterInternalVariables,
|
||||
separateEnvAndCubeVariables,
|
||||
} from './nopy.session.js';
|
||||
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
|
||||
|
||||
// History management
|
||||
export {
|
||||
loadHistory,
|
||||
saveHistory,
|
||||
addToHistory,
|
||||
getLastSession,
|
||||
getSessionById,
|
||||
listHistory,
|
||||
clearHistory,
|
||||
removeFromHistory,
|
||||
formatHistoryList,
|
||||
getHistoryPath,
|
||||
DEFAULT_HISTORY_SIZE,
|
||||
HISTORY_FILE,
|
||||
} from './nopy.history.js';
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
|
||||
// Configuration
|
||||
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
|
||||
export type {
|
||||
NopyConfig,
|
||||
NopyConfigFile,
|
||||
LogConfig,
|
||||
LogVerbosity,
|
||||
HistoryConfig,
|
||||
ExecutionConfig,
|
||||
ResolutionStrategy,
|
||||
ResolutionConfig,
|
||||
} from './nopy.config.js';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module nopy.cli
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from './nopy.config.js';
|
||||
import {
|
||||
@@ -16,12 +17,13 @@ import {
|
||||
} from './nopy.history.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
|
||||
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
|
||||
|
||||
const program = new Command();
|
||||
const config = loadConfig();
|
||||
|
||||
program
|
||||
.name('nopy')
|
||||
.version('1.0.0')
|
||||
.version(version)
|
||||
.description('A CLI tool for pyinfra script management and execution.')
|
||||
.addHelpText(
|
||||
'after',
|
||||
@@ -61,8 +63,8 @@ program
|
||||
.option('-j, --json', 'Output results as JSON')
|
||||
.option('--no-history', 'Do not save this session to history')
|
||||
.action(async (options) => {
|
||||
// Apply config defaults
|
||||
const execConfig = config.execution ?? {};
|
||||
// Loaded lazily so that --help/--version work outside a configured project.
|
||||
const execConfig = loadConfig().execution ?? {};
|
||||
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
|
||||
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user