Add release pipeline and upgrade toolchain to TypeScript 7
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:
Benjamin Diedrichsen
2026-07-27 15:17:14 +02:00
parent 736c01216a
commit 587ff2cf47
126 changed files with 6065 additions and 7544 deletions
+91
View File
@@ -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
+124
View File
@@ -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"
+243
View File
@@ -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"
+9
View File
@@ -7,9 +7,18 @@ cache
vault/tmp vault/tmp
age.key age.key
dist
coverage
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
*.tsbuildinfo
*.log *.log
.nopy.history.json .nopy.history.json
*.img *.img
*.img.gz *.img.gz
# Registry credentials and payloads written into the workspace by the publish
# workflows — never wanted in a commit.
.npmrc
.npmrc-*
release.json
+1
View File
@@ -0,0 +1 @@
24
+21
View File
@@ -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.
+402
View File
@@ -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.
+60
View File
@@ -1,2 +1,62 @@
# ansiblings # 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.
+16 -6
View File
@@ -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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@@ -7,11 +7,9 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"ignore": ["dist", "node_modules", ".yarn", "*.lock"] "includes": ["**", "!**/dist", "!**/node_modules", "!**/.yarn", "!**/*.lock"]
},
"organizeImports": {
"enabled": true
}, },
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space",
@@ -21,7 +19,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "preset": "recommended",
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "off"
}, },
@@ -38,5 +36,17 @@
"quoteStyle": "single", "quoteStyle": "single",
"trailingCommas": "es5" "trailingCommas": "es5"
} }
},
"overrides": [
{
"includes": ["**/tests/**"],
"linter": {
"rules": {
"performance": {
"noDelete": "off"
} }
}
}
}
]
} }
-1
View File
@@ -1,5 +1,4 @@
import { cubes } from '@bitstack/nopy'; import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({ export default cubes.Manifest({
id: 'admin:cockpit', id: 'admin:cockpit',
+7 -3
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { cubes, uniqid } from '@bitstack/nopy'; import { cubes, uniqid } from '@bitstack/nopy';
import { z } from 'zod';
/** /**
* Manifest for the admin:hostname cube. * Manifest for the admin:hostname cube.
@@ -10,7 +10,11 @@ export default cubes.Manifest({
name: 'Permanently change the hostname', name: 'Permanently change the hostname',
dependencies: () => [], dependencies: () => [],
schema: z.object({ schema: z.object({
HOSTNAME: z.string().min(1).max(64).describe('The new hostname for the target host') HOSTNAME: z
.string()
.min(1)
.max(64)
.describe('The new hostname for the target host')
.default(`host-${uniqid()}`), .default(`host-${uniqid()}`),
}) }),
}); });
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { cubes } from '@bitstack/nopy'; import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({ export default cubes.Manifest({
id: 'admin:locale', id: 'admin:locale',
+4 -1
View File
@@ -7,6 +7,9 @@ export default cubes.Manifest({
dependencies: () => [], dependencies: () => [],
schema: z.object({ schema: z.object({
UPDATE: z.boolean().describe('Update package cache before installing').default(false), 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'),
}), }),
}); });
+10 -4
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { cubes } from '@bitstack/nopy'; import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} // [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
@@ -13,7 +13,13 @@ export default cubes.Manifest({
schema: z.object({ schema: z.object({
SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'), SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'),
PASSWORD: z.string().min(8).describe('The password for the WiFi network'), PASSWORD: z.string().min(8).describe('The password for the WiFi network'),
AUTOCONNECT: z.boolean().default(true).describe('Whether to automatically connect to this network'), AUTOCONNECT: z
CONNECTION_NAME: z.string().optional().describe('Optional name for the connection (defaults to SSID)'), .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)'),
}),
}); });
+5 -1
View File
@@ -7,7 +7,11 @@ export default cubes.Manifest({
dependencies: () => [], dependencies: () => [],
schema: z.object({ schema: z.object({
APP: z.string().describe('The name of the systemd service (e.g., flintstone)'), 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), AUTOSTART: z.boolean().describe('Should the service be enabled and started?').default(true),
}), }),
}); });
+10 -4
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { cubes } from '@bitstack/nopy'; import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
// [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} // [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
@@ -14,7 +14,13 @@ export default cubes.Manifest({
schema: z.object({ schema: z.object({
USER: z.string().describe('The username of the account to modify'), USER: z.string().describe('The username of the account to modify'),
PASSWORD: z.string().optional().describe('New password for the user (optional)'), 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: z
GROUPS_ABSENT: z.string().optional().describe('Comma-separated list of groups to REMOVE from the user (optional)'), .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)'),
}),
}); });
-2340
View File
File diff suppressed because it is too large Load Diff
+17 -25
View File
@@ -1,41 +1,33 @@
{ {
"name": "@bitsquare/ansiblings", "name": "@bitsquare/ansiblings",
"private": true, "private": true,
"packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72",
"engines": { "engines": {
"node": ">=21" "node": ">=22"
}, },
"scripts": { "scripts": {
"build": "pnpm -r run build", "build": "pnpm -r run build",
"build:clean": "pnpm clean && pnpm build", "build:clean": "pnpm clean && pnpm build",
"build:tsgo": "tsgo --build",
"clean": "pnpm -r run clean", "clean": "pnpm -r run clean",
"test": "pnpm -r run test", "test": "pnpm -r run test",
"typecheck": "tsgo --noEmit", "test:coverage": "pnpm -r run test:coverage",
"typecheck:legacy": "tsc --noEmit", "coverage:summary": "node scripts/coverage-summary.mjs",
"typecheck": "tsc --build --noEmit",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "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": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^2.5.5",
"@logtape/logtape": "0.8.0", "@logtape/logtape": "^2.2.4",
"@types/jest": "29.5.5", "@types/node": "^26.1.1",
"@types/node": ">=21", "simple-git-hooks": "^2.13.1",
"@typescript/native-preview": "7.0.0-dev.20260303.1", "typescript": "^7.0.2"
"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"
}
} }
} }
+21
View File
@@ -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.
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
export {};
-11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
-50
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
-45
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
-37
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
-65
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
-115
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function keyman(): Promise<void>;
-79
View File
@@ -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
View File
@@ -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;
-21
View File
@@ -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;
}
}
+57 -19
View File
@@ -1,29 +1,67 @@
{ {
"name": "@bitstack/keyman", "name": "@bitstack/keyman",
"description": "A system to simplify ssh key management",
"type": "module",
"version": "1.0.0", "version": "1.0.0",
"private": true, "description": "A system to simplify ssh key management",
"keywords": [
"ssh",
"keys",
"age",
"encryption",
"cli"
],
"license": "MIT",
"author": "bitsquare", "author": "bitsquare",
"bin": "dist/keyman.cli.js", "type": "module",
"scripts": { "repository": {
"clean": "rm -rf dist", "type": "git",
"build": "tsgo && chmod +x dist/keyman.cli.js && npm link", "url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
"build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link", "directory": "packages/keyman"
"prepublishOnly": "npm run build", },
"keyman": "node --loader ts-node/esm src/keyman.bin.ts" "homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/keyman",
"bugs": {
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
}, },
"engines": { "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": { "dependencies": {
"execa": "9.5.2", "execa": "^10.0.0",
"inquirer": "8.2.4", "inquirer": "^14.0.2",
"ts-node": ">=10.9.1", "zod": "^4.4.3"
"typed-dotenv": "10.0.2", },
"typescript": ">=5.6.3", "devDependencies": {
"zod": "^3.24.1", "@types/node": "^26.1.1",
"zx": "^8.3.0" "@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
} }
} }
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env node #!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js'; export { loadConfig, resolveConfigPaths } from './keyman.config.js';
export * from './keyman.main.js';
+1 -1
View File
@@ -232,7 +232,7 @@ export function loadConfig(): KeymanConfig {
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
console.error('❌ ERROR: Invalid merged configuration:'); console.error('❌ ERROR: Invalid merged configuration:');
error.errors.forEach((err) => { error.issues.forEach((err) => {
console.error(` - ${err.path.join('.')}: ${err.message}`); console.error(` - ${err.path.join('.')}: ${err.message}`);
}); });
} }
-1
View File
@@ -1,6 +1,5 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import { loadConfig, resolveConfigPaths } from './keyman.config.js'; import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { copyKey } from './keyman.copy.js'; import { copyKey } from './keyman.copy.js';
+294
View File
@@ -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');
});
});
});
+133
View File
@@ -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');
});
});
+134
View File
@@ -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();
});
});
+141
View File
@@ -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);
});
});
+164
View File
@@ -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);
});
});
+215
View File
@@ -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:');
});
});
+216
View File
@@ -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');
});
});
+79
View File
@@ -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');
});
});
+2 -2
View File
@@ -1,13 +1,13 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", "tsBuildInfoFile": ".tsbuildinfo",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"lib": ["ES2020"], "lib": ["ES2020"],
"composite": true, "composite": true,
"module": "NodeNext", "module": "NodeNext",
"types": ["jest", "node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["coverage", "node_modules", "dist"], "exclude": ["coverage", "node_modules", "dist"],
+28
View File
@@ -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,
},
},
},
});
+21
View File
@@ -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
View File
@@ -1,5 +1,4 @@
import { cubes } from '@bitstack/nopy'; import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({ export default cubes.Manifest({
name: '[apt-all] Test dependencies', name: '[apt-all] Test dependencies',
@@ -1,5 +1,4 @@
import { cubes } from '@bitstack/nopy'; import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({ export default cubes.Manifest({
name: '[apt-more] Test dependencies', name: '[apt-more] Test dependencies',
-46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
-13
View File
@@ -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';
-17
View File
@@ -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';
-22
View File
@@ -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>;
-102
View File
@@ -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];
}
-74
View File
@@ -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[];
}
-57
View File
@@ -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 {};
}
}
}
-20
View File
@@ -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;
-33
View File
@@ -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('');
}
-20
View File
@@ -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';
-23
View File
@@ -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';
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env node
/**
* Nopy CLI - pyinfra deployment management
* @module nopy.cli
*/
export {};
-127
View File
@@ -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();
-20
View File
@@ -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;
}
-32
View File
@@ -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],
};
}
}
-114
View File
@@ -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[];
-263
View File
@@ -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;
}
-22
View File
@@ -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';
-16
View File
@@ -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
View File
@@ -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[];
};
-138
View File
@@ -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,
};
}
-90
View File
@@ -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;
-170
View File
@@ -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');
}
-38
View File
@@ -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>;
-163
View File
@@ -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,
},
};
}
-21
View File
@@ -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>;
-174
View File
@@ -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
}
}
-112
View File
@@ -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>;
};
-166
View File
@@ -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
View File
@@ -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>;
-149
View File
@@ -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
View File
@@ -1,44 +1,73 @@
{ {
"name": "@bitstack/nopy", "name": "@bitstack/nopy",
"description": "A system to simplify pyinfra script management and execution.",
"type": "module",
"version": "1.0.0", "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", "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": { "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": { "scripts": {
"clean": "rm -rf dist", "clean": "rm -rf dist .tsbuildinfo",
"build": "tsgo && chmod +x dist/nopy.cli.js && npm link", "build": "tsc",
"build:legacy": "tsc && chmod +x dist/nopy.cli.js && npm link", "prepack": "pnpm run build",
"prepublishOnly": "npm run build", "link:local": "pnpm run build && npm link",
"nopy": "node --loader ts-node/esm src/nopy.cli.ts", "nopy": "tsx src/nopy.cli.ts",
"debug": "node --inspect-brk --loader ts-node/esm src/nopy.cli.ts", "debug": "tsx --inspect-brk src/nopy.cli.ts",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --pool=forks", "test:integration": "vitest run --pool=forks",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"files": ["dist/"],
"dependencies": { "dependencies": {
"@logtape/logtape": "^0.8.0", "@logtape/logtape": "^2.2.4",
"commander": "^13.1.0", "commander": "^15.0.0",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"execa": "9.5.2", "execa": "^10.0.0",
"fuzzy": "^0.1.3", "fuzzy": "^0.1.3",
"inquirer": "8.2.4", "inquirer": "^14.0.2",
"inquirer-checkbox-plus-prompt": "^1.0.1", "zod": "^4.4.3",
"ts-node": ">=10.9.1", "zx": "^8.8.5"
"typescript": ">=5.6.3",
"yaml": "^2.8.2",
"zod": "^3.24.1",
"zx": "^8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/inquirer": "^8.2.10", "@types/node": "^26.1.1",
"@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.1.10",
"@types/uniqid": "^5.3.4", "tsx": "^4.23.1",
"vitest": "^1.6.0" "typescript": "^7.0.2",
"vitest": "^4.1.10"
} }
} }
+9 -5
View File
@@ -8,8 +8,8 @@ import type { Variables } from '../nopy.common.js';
import type { NopyConfig } from '../nopy.config.js'; import type { NopyConfig } from '../nopy.config.js';
import type { DeployCall } from '../nopy.executor.js'; import type { DeployCall } from '../nopy.executor.js';
import { VariableAssignment } from '../nopy.prompts.js'; import { VariableAssignment } from '../nopy.prompts.js';
import { type CubeSession, type NopySession } from '../nopy.session.js'; import type { CubeSession, NopySession } from '../nopy.session.js';
import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js'; import type { Cube, CubeVariables, HookContext } from './types.js';
const log = getLogger(['nopy', 'resolution']); const log = getLogger(['nopy', 'resolution']);
@@ -40,7 +40,11 @@ export class BuildContext {
/** /**
* Resolves a cube, its dependencies, and hooks recursively * 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]; const cube = this.allCubes[cubeId];
if (!cube) { if (!cube) {
throw new Error(`Cube not found: ${cubeId}`); throw new Error(`Cube not found: ${cubeId}`);
@@ -56,7 +60,7 @@ export class BuildContext {
// 2. Variable collection // 2. Variable collection
if (this.options.isSessionReplay) { 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) { if (sessionCube) {
this.variables.assign(cubeId, 'defaults', sessionCube.variables); this.variables.assign(cubeId, 'defaults', sessionCube.variables);
} }
@@ -128,7 +132,7 @@ export class BuildContext {
dependencies: [], dependencies: [],
}); });
if (!this.cubeSessions.some(s => s.key === cubeId)) { if (!this.cubeSessions.some((s) => s.key === cubeId)) {
this.cubeSessions.push({ this.cubeSessions.push({
key: cubeId, key: cubeId,
variables: this.variables.get(cubeId, 'prompts'), variables: this.variables.get(cubeId, 'prompts'),
+2 -2
View File
@@ -3,7 +3,7 @@
* @module cubes/factories * @module cubes/factories
*/ */
import { Manifest } from './types.js'; import { type AnyObjectSchema, Manifest } from './types.js';
/** /**
* Creates a manifest configuration for a cube * 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 * @param opts - Manifest options including name, schema, dependencies, and hooks
* @returns Manifest configuration object * @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'>> opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> { ): Manifest<Schema> {
return Manifest(opts); return Manifest(opts);
+21 -26
View File
@@ -6,37 +6,32 @@
* @module cubes * @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 // Types
export { export {
Cube, Cube,
Manifest, Manifest,
} from './types.js'; } 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 // Utilities
export { uniqid } from './utils.js'; export { uniqid } from './utils.js';
+12 -5
View File
@@ -5,6 +5,13 @@
import { z } from 'zod'; 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 * Variables that can be passed to a cube
*/ */
@@ -25,7 +32,7 @@ export interface HookContext {
/** /**
* Hook function type for before/after cube execution * 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, ctx: HookContext,
variables: z.infer<Schema> variables: z.infer<Schema>
) => void | Promise<void>; ) => void | Promise<void>;
@@ -33,7 +40,7 @@ export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
/** /**
* User-defined specification for a cube * 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) */ /** Unique identifier for the cube (used for dependency references) */
id: string; id: string;
/** Human-readable name of the cube */ /** 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 * 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'>> opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> { ): Manifest<Schema> {
return { return {
@@ -68,7 +75,7 @@ export namespace Manifest {
/** /**
* Internal create helper * 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'>> opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> { ): Manifest<Schema> {
return Manifest(opts); return Manifest(opts);
@@ -78,7 +85,7 @@ export namespace Manifest {
/** /**
* A fully loaded cube with its filesystem location and runtime state * 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( constructor(
public readonly manifest: Manifest<Schema>, public readonly manifest: Manifest<Schema>,
public readonly dir: string, public readonly dir: string,
+56 -64
View File
@@ -6,81 +6,73 @@
// Cubes module // Cubes module
export * from './cubes/index.js'; 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 // Backwards compatibility - cubes namespace
export { cubes } from './nopy.cubes.js'; export { cubes } from './nopy.cubes.js';
export type {
// Main entry point DeployCall,
export { nopy } from './nopy.main.js'; ExecutionOptions,
export type { NopyOptions, NopyResult } from './nopy.main.js'; ExecutionResult,
} from './nopy.executor.js';
// Executor // Executor
export { export {
executeDeployCalls, executeDeployCalls,
outputExecutionPlan, outputExecutionPlan,
summarizeResults, summarizeResults,
} from './nopy.executor.js'; } from './nopy.executor.js';
export type { export type { HistoryEntry, SessionHistory } from './nopy.history.js';
DeployCall, // History management
ExecutionResult, export {
ExecutionOptions, addToHistory,
} from './nopy.executor.js'; 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 // Workflow
export { export {
runWorkflow,
runInteractiveWorkflow, runInteractiveWorkflow,
runReplayWorkflow, runReplayWorkflow,
runSessionReplayWorkflow, runSessionReplayWorkflow,
runWorkflow,
} from './nopy.workflow.js'; } 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';
+6 -4
View File
@@ -5,6 +5,7 @@
* @module nopy.cli * @module nopy.cli
*/ */
import { createRequire } from 'node:module';
import { Command } from 'commander'; import { Command } from 'commander';
import { loadConfig } from './nopy.config.js'; import { loadConfig } from './nopy.config.js';
import { import {
@@ -16,12 +17,13 @@ import {
} from './nopy.history.js'; } from './nopy.history.js';
import { nopy } from './nopy.main.js'; import { nopy } from './nopy.main.js';
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
const program = new Command(); const program = new Command();
const config = loadConfig();
program program
.name('nopy') .name('nopy')
.version('1.0.0') .version(version)
.description('A CLI tool for pyinfra script management and execution.') .description('A CLI tool for pyinfra script management and execution.')
.addHelpText( .addHelpText(
'after', 'after',
@@ -61,8 +63,8 @@ program
.option('-j, --json', 'Output results as JSON') .option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history') .option('--no-history', 'Do not save this session to history')
.action(async (options) => { .action(async (options) => {
// Apply config defaults // Loaded lazily so that --help/--version work outside a configured project.
const execConfig = config.execution ?? {}; const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false; const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
try { try {

Some files were not shown because too many files have changed in this diff Show More